diff --git a/Cargo.lock b/Cargo.lock index a2c25e9e..8324319b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.4.4" @@ -361,6 +367,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.14.5" @@ -481,6 +493,17 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -528,9 +551,12 @@ dependencies = [ name = "pd-host-function" version = "0.1.0" dependencies = [ + "pd-host-schema", + "pd-vm", "proc-macro2", "quote", "syn", + "trybuild", ] [[package]] @@ -544,6 +570,14 @@ dependencies = [ "syn", ] +[[package]] +name = "pd-host-schema" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "pd-vm" version = "0.1.0" @@ -559,6 +593,7 @@ dependencies = [ "paste", "pd-edge-abi", "pd-host-function 0.1.0", + "pd-host-schema", "regex", "rt-format", "rusqlite", @@ -804,18 +839,47 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -839,14 +903,35 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "target-triple" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "tokio" version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -860,6 +945,60 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "trybuild" +version = "1.0.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -896,6 +1035,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasmtime-internal-core" version = "42.0.1" @@ -917,6 +1062,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1014,6 +1168,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "zerocopy" version = "0.8.56" diff --git a/Cargo.toml b/Cargo.toml index c59d0a0e..d403c684 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "pd-vm-nostd", "pd-vm-wasm", "crates/rustscript", + "crates/pd-host-schema", ] resolver = "2" @@ -27,6 +28,7 @@ name = "vm" [features] default = ["runtime", "cli", "cranelift-jit"] runtime = [] +async = ["runtime", "dep:tokio"] sqlite = ["runtime", "dep:rusqlite"] edge-abi = [ "dep:edge_abi", @@ -62,11 +64,12 @@ cranelift-module = { version = "0.129.1", optional = true } cranelift-native = { version = "0.129.1", optional = true } pd-host-function = { path = "./pd-host-function", version = "0.1.0" } rusqlite = { version = "0.32", default-features = false, features = ["bundled", "hooks", "limits"], optional = true } +tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync", "fs", "io-util", "process"], optional = true } edge_abi = { package = "pd-edge-abi", version = "0.1.1", default-features = false, optional = true } futures-channel = "0.3" paste = "1" regex = "1" -serde = "1" +serde = { version = "1", features = ["derive"] } serde_json = "1" rt-format = "0.3.1" self_cell = "1" @@ -79,6 +82,7 @@ windows-sys = { version = "0.59", features = ["Win32_System_Diagnostics_Debug", libc = "0.2" [dev-dependencies] +pd-host-schema = { path = "./crates/pd-host-schema", version = "0.1.0" } syn = { version = "2", features = ["full"] } tokio = { version = "1", features = ["macros", "rt", "time", "sync"] } @@ -87,5 +91,26 @@ name = "host_binding_generation_tests" path = "tests/host_binding_generation_tests.rs" required-features = ["cranelift-jit"] +[[test]] +name = "host_sdk_tests" +path = "tests/host_sdk_tests.rs" +required-features = ["runtime"] + +[[test]] +name = "host_resource_public_api_tests" +path = "tests/host_resource_public_api_tests.rs" +required-features = ["runtime"] + +[[test]] +name = "host_resource_macro_runtime_tests" +path = "tests/host_resource_macro_runtime_tests.rs" +required-features = ["runtime"] + +[[test]] +name = "host_context_arch_tests" +path = "tests/host_context_arch_tests.rs" +required-features = ["runtime"] + [build-dependencies] +pd-host-schema = { path = "./crates/pd-host-schema", version = "0.1.0" } syn = { version = "2", features = ["full"] } diff --git a/build.rs b/build.rs index ccc27766..5f6520af 100644 --- a/build.rs +++ b/build.rs @@ -22,10 +22,10 @@ struct SourceSpec { } #[derive(Clone, Debug)] -struct CallableParamDecl { - name: String, - ty_label: String, - optional: bool, +pub(crate) struct CallableParamDecl { + pub(crate) name: String, + pub(crate) ty_label: String, + pub(crate) optional: bool, } #[derive(Clone, Debug)] @@ -166,12 +166,21 @@ fn main() { catalog.retain(|entry| !entry.source_name.starts_with("sqlite::")); } - let host_sources = [SourceSpec { - path: "src/builtins/runtime/host.rs".to_string(), - module: "host".to_string(), - category: SourceCategory::DefaultHost, - }]; - let builtin_sources = builtin_source_specs(&namespaces); + let host_sources = vec![ + SourceSpec { + path: "src/builtins/runtime/host.rs".to_string(), + module: "host".to_string(), + category: SourceCategory::DefaultHost, + }, + SourceSpec { + path: "src/builtins/runtime/context_host.rs".to_string(), + module: "context_host".to_string(), + category: SourceCategory::DefaultHost, + }, + ]; + let async_enabled = env::var_os("CARGO_FEATURE_ASYNC").is_some(); + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").expect("missing target architecture"); + let builtin_sources = builtin_source_specs(&namespaces, async_enabled, &target_arch); let core_sources = [SourceSpec { path: "src/builtins/runtime/core.rs".to_string(), module: "core".to_string(), @@ -243,15 +252,26 @@ fn write_generated_file(path: &Path, contents: &str) { .unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display())); } -fn builtin_source_specs(namespaces: &[NamespaceDecl]) -> Vec { +pub(crate) fn select_io_source_path(async_enabled: bool, target_arch: &str) -> &'static str { + if target_arch == "wasm32" { + "src/builtins/runtime/io_wasm.rs" + } else if async_enabled { + "src/builtins/runtime/io/async_io.rs" + } else { + "src/builtins/runtime/io/blocking.rs" + } +} + +fn builtin_source_specs( + namespaces: &[NamespaceDecl], + async_enabled: bool, + target_arch: &str, +) -> Vec { namespaces .iter() .map(|namespace| { - // At this layer the `io` namespace maps directly to the blocking - // backend; the async/blocking split is introduced later with the - // host async execution layer. let path = if namespace.module == "io" { - "src/builtins/runtime/io/blocking.rs".to_string() + select_io_source_path(async_enabled, target_arch).to_string() } else { format!("src/builtins/runtime/{}.rs", namespace.module) }; @@ -281,6 +301,9 @@ fn parse_sources( } pub(crate) fn classify_host_binding(function: &ItemFn) -> HostBindingKind { + if function.sig.asyncness.is_some() { + return HostBindingKind::StaticStack; + } if function.sig.inputs.iter().any(|input| match input { FnArg::Typed(pat_type) => is_vm_context_type(&pat_type.ty), _ => false, @@ -306,6 +329,9 @@ pub(crate) fn classify_host_binding(function: &ItemFn) -> HostBindingKind { } pub(crate) fn infer_host_execution(function: &ItemFn) -> HostExecutionKind { + if function.sig.asyncness.is_some() { + return HostExecutionKind::MaySuspend; + } let return_type = normalized_return_type(&function.sig.output); if contains_host_call_result(&return_type) { HostExecutionKind::MaySuspend @@ -575,13 +601,6 @@ pub(crate) fn validate_catalog_contract( ); } for entry in entries { - let expected_variant = builtin_variant_name(&entry.source_name); - if expected_variant != entry.variant { - panic!( - "catalog variant mismatch for '{}': derived {expected_variant}, catalog {}", - entry.source_name, entry.variant - ); - } if (SQLITE_RESERVED_TOP_START..=SQLITE_RESERVED_TOP_END).contains(&entry.id) && !entry.source_name.starts_with("sqlite::") { @@ -592,6 +611,13 @@ pub(crate) fn validate_catalog_contract( entry.source_name, entry.id ); } + let expected_variant = builtin_variant_name(&entry.source_name); + if expected_variant != entry.variant { + panic!( + "catalog variant mismatch for '{}': derived {expected_variant}, catalog {}", + entry.source_name, entry.variant + ); + } let is_special_call = special_variants.contains(&entry.variant); match entry.class { CatalogClass::Ordinary => { @@ -989,6 +1015,7 @@ fn render_builtin_catalog( &actual_builtin_by_variant, ); render_builtin_signature_method(&mut out, &builtin_variant_order); + render_builtin_capability_method(&mut out, builtin_callables); writeln!( &mut out, " pub fn from_namespaced_name(name: &str) -> Option {{" @@ -1563,6 +1590,35 @@ fn required_param_count(params: &[CallableParamDecl]) -> usize { params.iter().take_while(|param| !param.optional).count() } +fn render_builtin_capability_method(out: &mut String, builtin_callables: &[CallableDecl]) { + let mut capability_variants = Vec::new(); + for callable in builtin_callables { + let variant = builtin_variant_name(&callable.name); + if !capability_variants.contains(&variant) { + capability_variants.push(variant); + } + } + capability_variants.sort(); + writeln!(out, " #[cfg(feature = \"runtime\")]").unwrap(); + writeln!( + out, + " pub(crate) const fn requires_explicit_host_capability(self) -> bool {{" + ) + .unwrap(); + if capability_variants.is_empty() { + writeln!(out, " false").unwrap(); + } else { + let patterns = capability_variants + .iter() + .map(|variant| format!("BuiltinFunction::{variant}")) + .collect::>() + .join(" | "); + writeln!(out, " matches!(self, {patterns})").unwrap(); + } + writeln!(out, " }}").unwrap(); + writeln!(out).unwrap(); +} + fn stable_groups(callables: &[CallableDecl], mut key_fn: F) -> Vec> where F: FnMut(&CallableDecl) -> String, @@ -1590,6 +1646,7 @@ fn callable_const_base(callable: &CallableDecl) -> String { } fn callable_param_variant(label: &str) -> &'static str { + let label = label.strip_suffix(" | null").unwrap_or(label); match label { "any" => "Any", "null" => "Null", @@ -1601,6 +1658,7 @@ fn callable_param_variant(label: &str) -> &'static str { "array" => "Array", "map" => "Map", "number" => "Number", + "resource" => "Resource", other => panic!("unsupported callable param type '{other}'"), } } @@ -1872,14 +1930,24 @@ fn host_wrapper_adapter_name(callable: &CallableDecl) -> String { } fn generated_wrapper_decl(function: &ItemFn) -> WrapperDecl { - let mut params = Vec::new(); + let mut needs_vm = function.sig.asyncness.is_some(); for input in &function.sig.inputs { let FnArg::Typed(pat_type) = input else { panic!("methods are not supported in #[pd_host_function] declarations"); }; if is_vm_context_type(&pat_type.ty) { - params.push(WrapperParamKind::Vm); + needs_vm = true; } + if pd_host_schema::resource_spec(&pat_type.ty, &pat_type.attrs) + .unwrap_or_else(|message| panic!("unsupported callable resource parameter: {message}")) + .is_some() + { + needs_vm = true; + } + } + let mut params = Vec::new(); + if needs_vm { + params.push(WrapperParamKind::Vm); } params.push(WrapperParamKind::SliceArgs); let fn_name = wrapper_name_for_callable(&function.sig.ident.to_string()); @@ -1890,7 +1958,7 @@ fn generated_wrapper_decl(function: &ItemFn) -> WrapperDecl { } } -fn parse_callable_params(function: &ItemFn) -> Vec { +pub(crate) fn parse_callable_params(function: &ItemFn) -> Vec { function .sig .inputs @@ -1899,13 +1967,22 @@ fn parse_callable_params(function: &ItemFn) -> Vec { let FnArg::Typed(pat_type) = input else { panic!("methods are not supported in #[pd_host_function] declarations"); }; + if pat_type + .attrs + .iter() + .any(|attr| attr.path().is_ident("pd_host_context")) + { + return None; + } if is_vm_context_type(&pat_type.ty) { return None; } let Pat::Ident(ident) = pat_type.pat.as_ref() else { panic!("callable parameters must use identifier patterns"); }; - let (ty_label, optional) = param_type_label(&pat_type.ty); + let (ty_label, optional) = + pd_host_schema::parameter_type_label_with_attrs(&pat_type.ty, &pat_type.attrs) + .unwrap_or_else(|message| panic!("unsupported callable parameter: {message}")); Some(CallableParamDecl { name: ident.ident.to_string(), ty_label, @@ -1915,37 +1992,6 @@ fn parse_callable_params(function: &ItemFn) -> Vec { .collect() } -fn param_type_label(ty: &Type) -> (String, bool) { - match ty { - Type::Group(group) => param_type_label(&group.elem), - Type::Paren(paren) => param_type_label(&paren.elem), - Type::Reference(reference) => param_type_label(&reference.elem), - Type::Path(path) => { - let segment = path - .path - .segments - .last() - .unwrap_or_else(|| panic!("unsupported callable type")); - if segment.ident == "Option" { - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - panic!("Option requires one generic argument"); - }; - let Some(syn::GenericArgument::Type(inner)) = args.args.first() else { - panic!("Option requires one generic argument"); - }; - let (inner_label, inner_optional) = param_type_label(inner); - if inner_optional { - panic!("nested Option is not supported in callable parameters"); - } - (inner_label, true) - } else { - (type_label(ty), false) - } - } - _ => (type_label(ty), false), - } -} - fn pd_host_function_name(attrs: &[Attribute]) -> Option { let attr = attrs .iter() @@ -2008,7 +2054,8 @@ fn callable_docs(name: &str, attrs: &[Attribute]) -> String { fn return_type_label(output: &ReturnType) -> String { match output { ReturnType::Default => "null".to_string(), - ReturnType::Type(_, ty) => type_label(ty), + ReturnType::Type(_, ty) => pd_host_schema::type_label(ty) + .unwrap_or_else(|message| panic!("unsupported callable return type: {message}")), } } @@ -2016,102 +2063,6 @@ fn static_return_type_label(output: &ReturnType) -> String { value_type_from_label(&return_type_label(output)).to_string() } -fn type_label(ty: &Type) -> String { - match ty { - Type::Group(group) => type_label(&group.elem), - Type::Paren(paren) => type_label(&paren.elem), - Type::Reference(reference) => type_label(&reference.elem), - Type::Slice(slice) => match slice.elem.as_ref() { - Type::Path(path) => { - let segment = path - .path - .segments - .last() - .unwrap_or_else(|| panic!("unsupported callable type")); - match segment.ident.to_string().as_str() { - "u8" => "bytes".to_string(), - _ => panic!("unsupported callable type"), - } - } - _ => panic!("unsupported callable type"), - }, - Type::Tuple(tuple) if tuple.elems.is_empty() => "null".to_string(), - Type::Path(path) => { - let segment = path - .path - .segments - .last() - .unwrap_or_else(|| panic!("unsupported callable type")); - let ident = segment.ident.to_string(); - match ident.as_str() { - "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" - | "u128" | "usize" => "int".to_string(), - "f32" | "f64" => "float".to_string(), - "bool" => "bool".to_string(), - "String" | "str" | "VmStringRef" => "string".to_string(), - "Bytes" | "VmBytes" | "VmBytesRef" | "VmBytesHandle" => "bytes".to_string(), - "Any" | "AnyValue" | "Value" | "VmValueRef" | "VmValueOwned" => "any".to_string(), - "Array" | "VmArray" | "VmArrayRef" | "VmArrayHandle" => "array".to_string(), - "Map" | "VmMap" | "VmMapRef" | "VmMapHandle" => "map".to_string(), - "Number" | "NumberValue" => "number".to_string(), - "Unknown" | "UnknownValue" => "unknown".to_string(), - "CallOutcome" => "unknown".to_string(), - "Option" => { - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - panic!("Option requires one generic argument"); - }; - let Some(syn::GenericArgument::Type(inner)) = args.args.first() else { - panic!("Option requires one generic argument"); - }; - format!("{} | null", type_label(inner)) - } - "VmResult" | "HostCallResult" => { - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - panic!("{ident} requires one generic argument"); - }; - let Some(syn::GenericArgument::Type(inner)) = args.args.first() else { - panic!("{ident} requires one generic argument"); - }; - type_label(inner) - } - "Vec" => type_label_for_vec(segment), - _ => panic!("unsupported callable type '{ident}'"), - } - } - _ => panic!("unsupported callable type"), - } -} - -fn type_label_for_vec(segment: &syn::PathSegment) -> String { - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - panic!("Vec requires one generic argument"); - }; - let Some(syn::GenericArgument::Type(inner)) = args.args.first() else { - panic!("Vec requires one generic argument"); - }; - if is_value_type(inner) { - return "array".to_string(); - } - match inner { - Type::Tuple(tuple) if tuple.elems.len() == 2 => { - let lhs = tuple - .elems - .first() - .expect("tuple should contain first element"); - let rhs = tuple - .elems - .last() - .expect("tuple should contain second element"); - if is_value_type(lhs) && is_value_type(rhs) { - "map".to_string() - } else { - panic!("unsupported Vec tuple type in callable metadata") - } - } - _ => panic!("unsupported Vec return type in callable metadata"), - } -} - fn value_type_from_label(label: &str) -> &'static str { match label { "null" => "Null", @@ -2140,20 +2091,6 @@ fn is_vm_context_type(ty: &Type) -> bool { } } -fn is_value_type(ty: &Type) -> bool { - match ty { - Type::Group(group) => is_value_type(&group.elem), - Type::Paren(paren) => is_value_type(&paren.elem), - Type::Reference(reference) => is_value_type(&reference.elem), - Type::Path(path) => path - .path - .segments - .last() - .is_some_and(|segment| segment.ident == "Value"), - _ => false, - } -} - fn to_shouty_snake(value: &str) -> String { let mut out = String::new(); let mut prev_is_lower_or_digit = false; @@ -2256,3 +2193,90 @@ fn find_matching_paren(source: &str) -> usize { } panic!("unterminated macro invocation"); } + +#[cfg(test)] +mod tests { + use super::{ + HostExecutionKind, NamespaceDecl, SourceCategory, builtin_source_specs, parse_source_file, + select_io_source_path, + }; + use std::path::Path; + + fn io_namespace() -> NamespaceDecl { + NamespaceDecl { + namespace: "io".to_string(), + module: "io".to_string(), + docs: "I/O".to_string(), + runtime_supported_on_wasm: false, + } + } + + #[test] + fn io_source_selection_matches_runtime_module_cfg() { + assert_eq!( + select_io_source_path(false, "x86_64"), + "src/builtins/runtime/io/blocking.rs" + ); + assert_eq!( + select_io_source_path(true, "x86_64"), + "src/builtins/runtime/io/async_io.rs" + ); + assert_eq!( + select_io_source_path(false, "aarch64"), + "src/builtins/runtime/io/blocking.rs" + ); + assert_eq!( + select_io_source_path(true, "aarch64"), + "src/builtins/runtime/io/async_io.rs" + ); + assert_eq!( + select_io_source_path(false, "wasm32"), + "src/builtins/runtime/io_wasm.rs" + ); + assert_eq!( + select_io_source_path(true, "wasm32"), + "src/builtins/runtime/io_wasm.rs" + ); + } + + #[test] + fn selected_io_source_drives_generated_metadata_input() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let namespace = io_namespace(); + for (async_enabled, target_arch) in [ + (false, "x86_64"), + (true, "x86_64"), + (false, "wasm32"), + (true, "wasm32"), + ] { + let specs = + builtin_source_specs(std::slice::from_ref(&namespace), async_enabled, target_arch); + let spec = specs + .iter() + .find(|spec| spec.category == SourceCategory::NamespacedBuiltin) + .expect("the IO namespace must produce a source spec"); + assert_eq!( + spec.path, + select_io_source_path(async_enabled, target_arch), + "metadata must use the same source selected by the runtime module" + ); + let source = std::fs::read_to_string(manifest_dir.join(&spec.path)) + .expect("selected IO source must be readable"); + let open_marker = if async_enabled && target_arch != "wasm32" { + "async fn builtin_io_open" + } else { + "fn builtin_io_open" + }; + assert!( + source.contains(open_marker), + "selected source must provide the expected IO implementation" + ); + let callables = parse_source_file(&manifest_dir.join(&spec.path), spec, 0); + let open = callables + .iter() + .find(|callable| callable.name == "io::open") + .expect("selected IO source must contain io::open"); + assert_eq!(open.host_execution, HostExecutionKind::MaySuspend); + } + } +} diff --git a/crates/pd-host-schema/Cargo.toml b/crates/pd-host-schema/Cargo.toml new file mode 100644 index 00000000..ee17bb65 --- /dev/null +++ b/crates/pd-host-schema/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pd-host-schema" +version.workspace = true +edition.workspace = true +description = "Shared host-schema parsing for the pd-host-function proc macro and the pd-vm build script" +license = "MIT" +homepage = "https://rustscript.org/" +repository = "https://github.com/rustscript-lang/rustscript" + +[dependencies] +proc-macro2 = "1" +syn = { version = "2", features = ["full", "extra-traits"] } diff --git a/crates/pd-host-schema/src/lib.rs b/crates/pd-host-schema/src/lib.rs new file mode 100644 index 00000000..89e0d274 --- /dev/null +++ b/crates/pd-host-schema/src/lib.rs @@ -0,0 +1,904 @@ +//! Canonical host-schema parsing shared by the `pd-host-function` proc macro +//! and the `pd-vm` build script. +//! +//! Both expansion paths must agree on how resource parameters are recognized +//! (the `ResourceRef` / `ResourceMut` / `ResourceOwned` wrappers plus the +//! `#[pd_host_param(passing = ..., key = ...)]` family of attributes), how the +//! resulting schema labels look, and which resource type keys are legal. +//! Centralizing those rules here guarantees that the descriptor generated by +//! the proc macro (ordered label / schema / passing / key) can never drift +//! from the descriptor the build script computes for the same signature. +//! +//! The crate is deliberately runtime-free (only `syn`/`proc-macro2`): it is +//! linked by a `proc-macro` crate and by a `build.rs`, neither of which can +//! depend on the VM. + +use std::fmt; + +use syn::{Attribute, GenericArgument, LitStr, Meta, PathArguments, Type}; + +/// Maximum byte length of a validated resource type key. +/// +/// This mirrors `pd_vm::host_api`'s `MAX_RESOURCE_KEY_LEN`; the proc macro and +/// the build script reject keys at expansion time with the exact same rules +/// the runtime applies, so an invalid key can never reach a runtime +/// `.expect()` panic. +pub const MAX_RESOURCE_KEY_LEN: usize = 128; + +/// Why a resource type key literal is invalid. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResourceKeyError { + Empty, + TooLong(usize), + InvalidChar { index: usize, ch: char }, + InvalidDotPlacement { index: usize }, +} + +impl fmt::Display for ResourceKeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "resource type key must not be empty"), + Self::TooLong(len) => write!( + f, + "resource type key is {len} bytes; the maximum is {MAX_RESOURCE_KEY_LEN}" + ), + Self::InvalidChar { index, ch } => write!( + f, + "resource type key contains invalid character {ch:?} at byte offset {index}" + ), + Self::InvalidDotPlacement { index } => write!( + f, + "resource type key contains an empty namespace segment at byte offset {index}" + ), + } + } +} + +impl std::error::Error for ResourceKeyError {} + +/// Validates a resource type key with the same rules as +/// `pd_vm::host_api::ResourceTypeKey::new`. +pub fn validate_resource_key(name: &str) -> Result<(), ResourceKeyError> { + if name.is_empty() { + return Err(ResourceKeyError::Empty); + } + if name.len() > MAX_RESOURCE_KEY_LEN { + return Err(ResourceKeyError::TooLong(name.len())); + } + // Allowed: ASCII lowercase a-z, 0-9, '_' and '-', with '.' used purely as + // a namespace separator between non-empty segments. + for (index, b) in name.bytes().enumerate() { + let valid = b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.'); + if !valid { + return Err(ResourceKeyError::InvalidChar { + index, + ch: name[index..].chars().next().unwrap_or('\u{fffd}'), + }); + } + } + // Report the exact byte offset of each empty segment: a '.' that directly + // follows another '.' (or the leading dot) opens an empty segment at that + // dot, and a trailing '.' leaves an empty segment at the end of the name. + let mut segment_start = 0usize; + for (index, b) in name.bytes().enumerate() { + if b == b'.' { + if index == segment_start { + return Err(ResourceKeyError::InvalidDotPlacement { index }); + } + segment_start = index + 1; + } + } + if segment_start == name.len() { + return Err(ResourceKeyError::InvalidDotPlacement { + index: segment_start, + }); + } + Ok(()) +} + +/// The four resource passing modes the adapter layer understands. +/// +/// `to_owned` is **not** a host passing mode: a guest-side `to_owned()` +/// expression is ordinary `Value` passing, and asking the adapter for a +/// resource-containing `to_owned` frame is rejected with an explicit +/// "reserved" error instead of being silently aliased to `Value` or +/// `TakeOwned`. +/// +/// This mirrors `pd_vm::vm::resource::ResourceAccessMode`; it is kept +/// runtime-free here so both the proc macro and the build script can share the +/// parsing rules. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResourceMode { + Borrow, + BorrowMut, + TakeOwned, + Value, +} + +/// The coarse host-parameter passing categories emitted into catalog metadata. +/// This mirrors `pd_vm::host_api::HostParamPassing`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostPassing { + Value, + Borrow, + BorrowMut, + TakeOwned, +} + +impl fmt::Display for HostPassing { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Value => "value", + Self::Borrow => "borrow", + Self::BorrowMut => "borrow_mut", + Self::TakeOwned => "take_owned", + }) + } +} + +impl ResourceMode { + /// Normalizes and parses a `passing = "..."` string literal. + /// + /// `to_owned` / `toowned` are explicitly **reserved**: they return an error + /// naming the reserved mode rather than silently aliasing it to `Value` or + /// `TakeOwned`. + pub fn parse(value: &str) -> Result { + let normalized = value.to_ascii_lowercase().replace('-', "_"); + match normalized.as_str() { + "borrow" => Ok(Self::Borrow), + "borrow_mut" | "borrowmut" => Ok(Self::BorrowMut), + "take_owned" | "takeowned" | "owned" => Ok(Self::TakeOwned), + "to_owned" | "toowned" => Err( + "to_owned passing is reserved and unsupported; use take_owned to transfer \ + resource ownership" + .to_string(), + ), + "value" => Ok(Self::Value), + _ => { + Err("resource passing must be borrow, borrow_mut, take_owned, or value".to_string()) + } + } + } + + /// The catalog passing category. There is no host `ToOwned` category: the + /// only non-resource category is `Value`. + pub fn host_passing(self) -> HostPassing { + match self { + Self::Borrow => HostPassing::Borrow, + Self::BorrowMut => HostPassing::BorrowMut, + Self::TakeOwned => HostPassing::TakeOwned, + Self::Value => HostPassing::Value, + } + } + + /// Whether accessing this mode consumes the resource slot. + pub const fn is_consuming(self) -> bool { + matches!(self, Self::TakeOwned) + } +} + +/// Schema label used for a resource parameter or return (mirrors the proc +/// macro's `"resource"` label and the runtime `HostTypeSchema::Resource`). +pub const RESOURCE_SCHEMA_LABEL: &str = "resource"; + +/// Parsed resource parameter/return metadata. +#[derive(Clone, Debug)] +pub struct ResourceSpec { + /// The resolved passing mode (from the canonical wrapper or the attribute). + pub mode: ResourceMode, + /// The concrete resource type. For canonical wrappers this is the wrapper's + /// type argument; for annotation-only declarations it is the declared type. + pub inner: Type, + /// Whether the declaration used a canonical owning wrapper (`ResourceOwned`). + pub owned_wrapper: bool, + /// An explicit `key = "..."` literal if one was declared. Already validated. + pub key: Option, +} + +/// Kind of a resource *return* type. Only the owned `Resource` wrapper may +/// cross the host boundary; borrowed wrappers must be rejected by callers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResourceReturnKind { + /// `Resource` — an owned handle token. + Owned, + /// `ResourceRef<'_, T>` — a borrow that must not cross the boundary. + Borrow, + /// `ResourceMut<'_, T>` — a mutable borrow that must not cross the boundary. + BorrowMut, +} + +/// Unwraps grouping/parenthesized surface syntax. +fn unwrap_surface(ty: &Type) -> &Type { + let mut current = ty; + loop { + current = match current { + Type::Group(group) => &group.elem, + Type::Paren(paren) => &paren.elem, + other => return other, + }; + } +} + +/// The final path segment identifier of the surface type, if it is a path. +pub fn path_last_ident(ty: &Type) -> Option { + let ty = unwrap_surface(ty); + let Type::Path(path) = ty else { + return None; + }; + path.path + .segments + .last() + .map(|segment| segment.ident.to_string()) +} + +/// Parses the resource-passing attributes on a parameter into an explicit mode +/// and an explicit key. Mirrors the proc macro's `parse_resource_attrs`. +pub fn parse_resource_attrs( + attrs: &[Attribute], +) -> Result<(Option, Option), String> { + let mut mode = None; + let mut key = None; + for attr in attrs { + let path = attr.path(); + if path.is_ident("pd_borrow") { + mode = Some(ResourceMode::Borrow); + continue; + } + if path.is_ident("pd_borrow_mut") { + mode = Some(ResourceMode::BorrowMut); + continue; + } + if path.is_ident("pd_take_owned") { + mode = Some(ResourceMode::TakeOwned); + continue; + } + if path.is_ident("pd_to_owned") { + return Err( + "pd_to_owned is reserved and unsupported; use pd_take_owned to transfer \ + resource ownership" + .to_string(), + ); + } + if path.is_ident("pd_value") { + mode = Some(ResourceMode::Value); + continue; + } + if !(path.is_ident("pd_host_param") + || path.is_ident("pd_host_resource") + || path.is_ident("pd_host_passing")) + { + continue; + } + match &attr.meta { + Meta::Path(_) => {} + Meta::NameValue(name_value) => { + let syn::Expr::Lit(expr_lit) = &name_value.value else { + return Err("resource passing metadata must be a string literal".to_string()); + }; + let syn::Lit::Str(value) = &expr_lit.lit else { + return Err("resource passing metadata must be a string literal".to_string()); + }; + if name_value.path.is_ident("passing") || path.is_ident("pd_host_passing") { + mode = Some(ResourceMode::parse(value.value().as_str())?); + } else if name_value.path.is_ident("key") { + key = Some(value.value()); + } else { + return Err("expected passing = \"...\" or key = \"...\"".to_string()); + } + } + Meta::List(_) => { + attr.parse_nested_meta(|nested| { + if nested.path.is_ident("borrow") { + mode = Some(ResourceMode::Borrow); + return Ok(()); + } + if nested.path.is_ident("borrow_mut") || nested.path.is_ident("borrowmut") { + mode = Some(ResourceMode::BorrowMut); + return Ok(()); + } + if nested.path.is_ident("take_owned") + || nested.path.is_ident("takeowned") + || nested.path.is_ident("owned") + { + mode = Some(ResourceMode::TakeOwned); + return Ok(()); + } + if nested.path.is_ident("to_owned") || nested.path.is_ident("toowned") { + return Err(nested.error( + "to_owned is reserved and unsupported; use take_owned to transfer \ + resource ownership", + )); + } + if nested.path.is_ident("value") { + mode = Some(ResourceMode::Value); + return Ok(()); + } + if nested.path.is_ident("passing") { + let value: LitStr = nested.value()?.parse()?; + mode = Some( + ResourceMode::parse(value.value().as_str()) + .map_err(|msg| syn::Error::new(value.span(), msg))?, + ); + return Ok(()); + } + if nested.path.is_ident("key") { + key = Some(nested.value()?.parse::()?.value()); + return Ok(()); + } + Err(nested.error( + "expected a resource passing mode, passing = \"...\", or key = \"...\"", + )) + }) + .map_err(|err| err.to_string())?; + } + } + } + Ok((mode, key)) +} + +/// The canonical resource wrapper names that the adapter can expand reliably. +pub const CANONICAL_WRAPPERS: [&str; 3] = ["ResourceRef", "ResourceMut", "ResourceOwned"]; + +/// Whether `ident` names a canonical resource wrapper. +pub fn is_canonical_wrapper(ident: &str) -> bool { + matches!(ident, "ResourceRef" | "ResourceMut" | "ResourceOwned") +} + +/// Extracts the single concrete type argument of a path segment (the last type +/// argument, so a `ResourceRef<'_, T>` lifetime prefix is skipped). +pub fn generic_type_argument(segment: &syn::PathSegment) -> Result { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return Err("resource wrapper requires one concrete resource type".to_string()); + }; + args.args + .iter() + .rev() + .find_map(|arg| match arg { + GenericArgument::Type(ty) => Some(ty.clone()), + _ => None, + }) + .ok_or_else(|| "resource wrapper requires one concrete resource type".to_string()) +} + +/// Parses one parameter into resource metadata, or `None` for an ordinary +/// parameter. This is the single canonical rule used by both the proc macro +/// and the build script, so their descriptors can never diverge. +/// +/// Errors are plain messages; the proc macro re-spans them onto the parameter +/// type and the build script turns them into build failures. +pub fn resource_spec(ty: &Type, attrs: &[Attribute]) -> Result, String> { + let (explicit_mode, key) = parse_resource_attrs(attrs)?; + let wrapper = path_last_ident(ty); + let Some(wrapper) = wrapper else { + return if explicit_mode.is_some() { + Err("resource passing metadata requires a concrete resource type".to_string()) + } else { + Ok(None) + }; + }; + let inferred = match wrapper.as_str() { + "ResourceRef" => Some((ResourceMode::Borrow, true)), + "ResourceMut" => Some((ResourceMode::BorrowMut, true)), + "ResourceOwned" => Some((ResourceMode::TakeOwned, true)), + _ => None, + }; + let Some((inferred_mode, owned_wrapper)) = + inferred.or_else(|| explicit_mode.map(|mode| (mode, false))) + else { + return Ok(None); + }; + let mode = explicit_mode.unwrap_or(inferred_mode); + if explicit_mode.is_some() && inferred.is_some() && mode != inferred_mode { + return Err("resource wrapper and passing metadata specify different modes".to_string()); + } + if matches!(mode, ResourceMode::Value) { + return Err( + "resource-containing Value parameters are rejected; use Borrow, BorrowMut, or TakeOwned" + .to_string(), + ); + } + // An explicit annotation on a bare identifier is a concrete resource type + // (e.g. `#[pd_host_param(passing = "take_owned")] r: FakeResource`). A + // path that carries generic arguments or a qualified prefix cannot be a + // concrete `HostResource` type and is almost always a type alias to a + // resource wrapper, which the macro cannot resolve reliably. + if explicit_mode.is_some() && inferred.is_none() { + let path = match unwrap_surface(ty) { + Type::Path(path) => path, + _ => unreachable!("path_last_ident only yields for path types"), + }; + let has_suspicious_shape = path.path.segments.len() > 1 + || matches!( + path.path.segments.last().map(|s| &s.arguments), + Some(PathArguments::AngleBracketed(_) | PathArguments::Parenthesized(_)) + ); + if has_suspicious_shape { + return Err( + "resource passing metadata on an alias/unqualified wrapper path is not supported; use a canonical ResourceRef, ResourceMut, or ResourceOwned wrapper or a bare concrete resource type" + .to_string(), + ); + } + } + let inner = if inferred.is_some() { + let Type::Path(path) = unwrap_surface(ty) else { + unreachable!("canonical wrapper is a path type") + }; + generic_type_argument( + path.path + .segments + .last() + .expect("canonical resource wrapper segment"), + )? + } else { + (*ty).clone() + }; + if let Some(key) = &key { + validate_resource_key(key).map_err(|error| error.to_string())?; + } + Ok(Some(ResourceSpec { + mode, + inner, + owned_wrapper, + key, + })) +} + +/// Converts a supported Rust type syntax into the canonical host-schema label. +/// +/// This parser is shared by the proc macro and the root build script. It +/// intentionally returns strings so the runtime-free schema crate can be used +/// by both compilation phases without depending on the VM's schema model. +pub fn type_label(ty: &Type) -> Result { + match ty { + Type::Group(group) => type_label(&group.elem), + Type::Paren(paren) => type_label(&paren.elem), + Type::Reference(reference) => type_label(&reference.elem), + Type::Slice(slice) => match slice.elem.as_ref() { + Type::Path(path) if path.path.segments.last().is_some_and(|s| s.ident == "u8") => { + Ok("bytes".to_string()) + } + _ => Err("unsupported callable type".to_string()), + }, + Type::Tuple(tuple) if tuple.elems.is_empty() => Ok("null".to_string()), + Type::Path(path) => { + let Some(segment) = path.path.segments.last() else { + return Err("unsupported callable type".to_string()); + }; + let ident = segment.ident.to_string(); + match ident.as_str() { + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" + | "u128" | "usize" => Ok("int".to_string()), + "f32" | "f64" => Ok("float".to_string()), + "bool" => Ok("bool".to_string()), + "String" | "str" | "VmStringRef" => Ok("string".to_string()), + "Bytes" | "VmBytes" | "VmBytesRef" | "VmBytesHandle" => Ok("bytes".to_string()), + "Any" | "AnyValue" | "Value" | "VmValueRef" | "VmValueOwned" => { + Ok("any".to_string()) + } + "Array" | "VmArray" | "VmArrayRef" | "VmArrayHandle" => Ok("array".to_string()), + "Map" | "VmMap" | "VmMapRef" | "VmMapHandle" => Ok("map".to_string()), + "Number" | "NumberValue" => Ok("number".to_string()), + "Resource" | "ResourceRef" | "ResourceMut" => Ok(RESOURCE_SCHEMA_LABEL.to_string()), + "ResourceOwned" => Err( + "ResourceOwned is input-only; it cannot be used as a return schema".to_string(), + ), + "VmCallable" => callable_type_label(segment), + "Unknown" | "UnknownValue" => Ok("unknown".to_string()), + "CallOutcome" => Ok("unknown".to_string()), + "Option" => { + let args = one_type_argument(segment, "Option")?; + Ok(format!("{} | null", type_label(args)?)) + } + "VmResult" | "HostCallResult" | "HostFutureOutput" => { + let args = one_type_argument(segment, &ident)?; + type_label(args) + } + "Vec" => type_label_for_vec(segment), + _ => Err(format!("unsupported callable type '{ident}'")), + } + } + _ => Err("unsupported callable type".to_string()), + } +} + +/// Returns a canonical type label and whether the parameter is an `Option`, +/// honoring resource-passing attributes on concrete types. +pub fn parameter_type_label_with_attrs( + ty: &Type, + attrs: &[Attribute], +) -> Result<(String, bool), String> { + if resource_spec(ty, attrs)?.is_some() { + return Ok((RESOURCE_SCHEMA_LABEL.to_string(), false)); + } + parameter_type_label_inner(ty) +} + +/// Returns a canonical type label and whether the parameter is an `Option`. +pub fn parameter_type_label(ty: &Type) -> Result<(String, bool), String> { + parameter_type_label_with_attrs(ty, &[]) +} + +fn parameter_type_label_inner(ty: &Type) -> Result<(String, bool), String> { + let optional = path_last_ident(ty).as_deref() == Some("Option"); + Ok((type_label(ty)?, optional)) +} + +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")); + }; + let Some(GenericArgument::Type(inner)) = args.args.first() else { + return Err(format!("{name} requires one type argument")); + }; + if args.args.len() != 1 { + return Err(format!("{name} requires one generic argument")); + } + Ok(inner) +} + +fn callable_type_label(segment: &syn::PathSegment) -> Result { + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return Err("VmCallable requires a function signature".to_string()); + }; + let Some(GenericArgument::Type(Type::BareFn(function))) = args.args.first() else { + return Err("VmCallable requires fn(...) -> ...".to_string()); + }; + if args.args.len() != 1 { + return Err("VmCallable requires one function signature".to_string()); + } + let params = function + .inputs + .iter() + .map(|input| type_label(&input.ty)) + .collect::, _>>()?; + let result = match &function.output { + syn::ReturnType::Default => "null".to_string(), + syn::ReturnType::Type(_, ty) => type_label(ty)?, + }; + Ok(format!("fn({}) -> {result}", params.join(", "))) +} + +fn type_label_for_vec(segment: &syn::PathSegment) -> Result { + let inner = one_type_argument(segment, "Vec")?; + match inner { + Type::Tuple(tuple) if tuple.elems.len() == 2 => { + let lhs = tuple + .elems + .first() + .expect("two-element tuple has a first element"); + let rhs = tuple + .elems + .last() + .expect("two-element tuple has a last element"); + if is_value_type(lhs) && is_value_type(rhs) { + Ok("map".to_string()) + } else { + Err("unsupported Vec tuple type in callable metadata".to_string()) + } + } + _ if is_value_type(inner) => Ok("array".to_string()), + _ => { + let inner_label = type_label(inner)?; + Err(format!("unsupported Vec return type '{inner_label}'")) + } + } +} + +fn is_value_type(ty: &Type) -> bool { + match ty { + Type::Group(group) => is_value_type(&group.elem), + Type::Paren(paren) => is_value_type(&paren.elem), + Type::Reference(reference) => is_value_type(&reference.elem), + Type::Path(path) => path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "Value"), + _ => false, + } +} + +/// Classifies a *return* type as an owned `Resource` token, a borrowed +/// `ResourceRef<'_, T>`, or a `ResourceMut<'_, T>`. Returns `None` for anything +/// that is not a resource wrapper. +pub fn resource_return_kind(ty: &Type) -> Option { + let ident = path_last_ident(ty)?; + match ident.as_str() { + "Resource" => Some(ResourceReturnKind::Owned), + "ResourceRef" => Some(ResourceReturnKind::Borrow), + "ResourceMut" => Some(ResourceReturnKind::BorrowMut), + _ => None, + } +} + +/// A borrowed resource wrapper found while walking a host function return type. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BorrowedResourceReturn { + /// The borrowed wrapper that cannot cross the host boundary. + pub kind: ResourceReturnKind, + /// Transparent return wrappers from the outermost type to the resource. + pub wrappers: Vec, +} + +/// Finds a borrowed resource wrapper nested inside supported return wrappers. +/// +/// `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. +pub fn borrowed_resource_return(ty: &Type) -> Option { + let ty = unwrap_return_surface(ty); + if let Some(kind) = resource_return_kind(ty) { + return match kind { + ResourceReturnKind::Borrow | ResourceReturnKind::BorrowMut => { + Some(BorrowedResourceReturn { + kind, + wrappers: Vec::new(), + }) + } + ResourceReturnKind::Owned => None, + }; + } + + let Type::Path(path) = ty else { + return None; + }; + 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; + }; + for argument in &args.args { + let GenericArgument::Type(inner) = argument else { + continue; + }; + let Some(mut found) = borrowed_resource_return(inner) else { + continue; + }; + found.wrappers.insert(0, wrapper.clone()); + return Some(found); + } + None +} + +fn unwrap_return_surface(ty: &Type) -> &Type { + let mut current = ty; + loop { + current = match current { + Type::Group(group) => &group.elem, + Type::Paren(paren) => &paren.elem, + Type::Reference(reference) => &reference.elem, + other => return other, + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::parse_quote; + + #[test] + fn key_validation_matches_expected_rules() { + assert!(validate_resource_key("io.file").is_ok()); + assert!(validate_resource_key("file").is_ok()); + assert!(validate_resource_key("a-b.c_0").is_ok()); + assert_eq!(validate_resource_key(""), Err(ResourceKeyError::Empty)); + assert!(matches!( + validate_resource_key("Io.File").unwrap_err(), + ResourceKeyError::InvalidChar { .. } + )); + assert!(matches!( + validate_resource_key("io..file").unwrap_err(), + ResourceKeyError::InvalidDotPlacement { .. } + )); + assert!(matches!( + validate_resource_key(".io.file").unwrap_err(), + ResourceKeyError::InvalidDotPlacement { .. } + )); + assert!(matches!( + validate_resource_key("io.file.").unwrap_err(), + ResourceKeyError::InvalidDotPlacement { .. } + )); + let overlong = "a".repeat(MAX_RESOURCE_KEY_LEN + 1); + assert!(matches!( + validate_resource_key(&overlong).unwrap_err(), + ResourceKeyError::TooLong(len) if len == MAX_RESOURCE_KEY_LEN + 1 + )); + } + + #[test] + fn canonical_wrappers_infer_modes() { + let ty: Type = parse_quote!(ResourceRef<'_, FakeResource>); + let spec = resource_spec(&ty, &[]).unwrap().expect("resource"); + assert_eq!(spec.mode, ResourceMode::Borrow); + assert!(spec.owned_wrapper); + + let ty: Type = parse_quote!(ResourceMut<'_, FakeResource>); + let spec = resource_spec(&ty, &[]).unwrap().expect("resource"); + assert_eq!(spec.mode, ResourceMode::BorrowMut); + + let ty: Type = parse_quote!(ResourceOwned); + let spec = resource_spec(&ty, &[]).unwrap().expect("resource"); + assert_eq!(spec.mode, ResourceMode::TakeOwned); + assert!(spec.owned_wrapper); + } + + #[test] + fn ordinary_parameters_are_not_resources() { + let ty: Type = parse_quote!(i64); + assert!(resource_spec(&ty, &[]).unwrap().is_none()); + let ty: Type = parse_quote!(String); + assert!(resource_spec(&ty, &[]).unwrap().is_none()); + } + + #[test] + fn explicit_annotation_on_concrete_type_is_supported() { + let ty: Type = parse_quote!(FakeResource); + let attrs: Vec = + parse_quote!(#[pd_host_param(passing = "take_owned", key = "test.fake")]); + let spec = resource_spec(&ty, &attrs).unwrap().expect("resource"); + assert_eq!(spec.mode, ResourceMode::TakeOwned); + assert_eq!(spec.key.as_deref(), Some("test.fake")); + assert!(!spec.owned_wrapper); + } + + #[test] + fn annotation_mode_conflict_is_rejected() { + let ty: Type = parse_quote!(ResourceOwned); + let attrs: Vec = parse_quote!(#[pd_host_param(passing = "borrow")]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("different modes"), "{error}"); + } + + #[test] + fn to_owned_and_value_resource_modes_are_rejected() { + // `to_owned` / `toowned` are reserved at parse time (never aliased to + // Value or TakeOwned): the literal, the attribute, and the nested form + // all fail with an explicit "reserved" error. + for literal in ["to_owned", "toowned", "TO_OWNED"] { + let error = ResourceMode::parse(literal).expect_err("to_owned must be reserved"); + assert!(error.contains("reserved"), "{literal}: {error}"); + } + let ty: Type = parse_quote!(FakeResource); + let attrs: Vec = parse_quote!(#[pd_host_param(passing = "to_owned")]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("reserved"), "{error}"); + let attrs: Vec = parse_quote!(#[pd_to_owned]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("reserved"), "{error}"); + let attrs: Vec = parse_quote!(#[pd_host_passing(to_owned)]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("reserved"), "{error}"); + + // `value` on a resource type is rejected by the spec (not reserved). + let ty: Type = parse_quote!(FakeResource); + let attrs: Vec = parse_quote!(#[pd_host_param(passing = "value")]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("Value"), "{error}"); + assert!(error.contains("rejected"), "{error}"); + } + + #[test] + fn invalid_explicit_keys_are_rejected_at_parse_time() { + let ty: Type = parse_quote!(FakeResource); + for key in ["", "bad key", "io..file", "A.b"] { + let attrs: Vec = + parse_quote!(#[pd_host_param(passing = "take_owned", key = #key)]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("resource type key"), "{error}"); + } + } + + #[test] + fn alias_wrapper_shape_with_annotation_is_rejected() { + // A path whose final segment is a canonical wrapper is a qualified + // (e.g. re-exported) canonical wrapper and is fully supported. + let ty: Type = parse_quote!(my_alias::ResourceRef<'static, FakeResource>); + let attrs: Vec = parse_quote!(#[pd_host_param(passing = "borrow")]); + let spec = resource_spec(&ty, &attrs) + .unwrap() + .expect("qualified wrapper"); + assert_eq!(spec.mode, ResourceMode::Borrow); + + // A non-canonical path that carries a qualified prefix or generic + // arguments cannot be a concrete `HostResource` type and is almost + // always an alias the parser cannot expand reliably. + let ty: Type = parse_quote!(my_alias::Wrapper<'static, FakeResource>); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("alias"), "{error}"); + + let ty: Type = parse_quote!(WrapperAlias); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("alias"), "{error}"); + } + + #[test] + fn resource_return_kinds_are_classified() { + let ty: Type = parse_quote!(Resource); + assert_eq!(resource_return_kind(&ty), Some(ResourceReturnKind::Owned)); + let ty: Type = parse_quote!(ResourceRef<'_, FakeResource>); + assert_eq!(resource_return_kind(&ty), Some(ResourceReturnKind::Borrow)); + let ty: Type = parse_quote!(ResourceMut<'_, FakeResource>); + assert_eq!( + resource_return_kind(&ty), + Some(ResourceReturnKind::BorrowMut) + ); + let ty: Type = parse_quote!(i64); + assert_eq!(resource_return_kind(&ty), None); + } + + #[test] + fn borrowed_resource_return_shape_recurses_transparent_wrappers() { + let ty: Type = parse_quote!(VmResult>); + let found = borrowed_resource_return(&ty).expect("borrowed resource"); + assert_eq!(found.kind, ResourceReturnKind::Borrow); + assert_eq!(found.wrappers, vec!["VmResult"]); + + let ty: Type = parse_quote!(Option>); + let found = borrowed_resource_return(&ty).expect("borrowed mutable resource"); + assert_eq!(found.kind, ResourceReturnKind::BorrowMut); + assert_eq!(found.wrappers, vec!["Option"]); + + let ty: Type = parse_quote!(Result>, FakeError>); + let found = borrowed_resource_return(&ty).expect("nested borrowed resource"); + assert_eq!(found.kind, ResourceReturnKind::Borrow); + assert_eq!(found.wrappers, vec!["Result", "Option"]); + + let ty: Type = parse_quote!(VmResult>>); + assert!(borrowed_resource_return(&ty).is_none()); + } + + #[test] + fn host_passing_mapping_matches_the_runtime() { + assert_eq!(ResourceMode::Borrow.host_passing(), HostPassing::Borrow); + assert_eq!( + ResourceMode::BorrowMut.host_passing(), + HostPassing::BorrowMut + ); + assert_eq!( + ResourceMode::TakeOwned.host_passing(), + HostPassing::TakeOwned + ); + assert_eq!(ResourceMode::Value.host_passing(), HostPassing::Value); + assert!(!matches!( + ResourceMode::Value.host_passing(), + HostPassing::TakeOwned + )); + } + + #[test] + fn shared_type_parser_handles_optional_and_callable_types() { + let ty: Type = parse_quote!(Option VmMap>>); + assert_eq!( + type_label(&ty).unwrap(), + "fn(map) -> map | null", + "the shared parser is the schema source for both generators" + ); + assert_eq!( + parameter_type_label(&ty).unwrap(), + ("fn(map) -> map | null".into(), true) + ); + } + + #[test] + fn shared_type_parser_labels_resource_wrappers_consistently() { + for ty in [ + parse_quote!(Resource), + parse_quote!(ResourceRef<'_, FakeResource>), + parse_quote!(ResourceMut<'_, FakeResource>), + ] { + assert_eq!(type_label(&ty).unwrap(), RESOURCE_SCHEMA_LABEL); + } + let owned: Type = parse_quote!(ResourceOwned); + let error = type_label(&owned).unwrap_err(); + assert!(error.contains("input-only"), "{error}"); + } +} diff --git a/crates/rustscript/tests/alias_smoke.rs b/crates/rustscript/tests/alias_smoke.rs index c58bb302..5b709560 100644 --- a/crates/rustscript/tests/alias_smoke.rs +++ b/crates/rustscript/tests/alias_smoke.rs @@ -21,3 +21,31 @@ fn alias_exports_op_code() { let _ = rustscript::OpCode::Nop; let _ = rustscript::OpCode::Add; } + +#[cfg(feature = "runtime")] +#[test] +fn alias_exports_public_invocation_stream_contract() { + fn accept_item(_item: rustscript::InvocationItem) {} + + accept_item(rustscript::InvocationItem::Complete( + rustscript::Value::Null, + )); + accept_item(rustscript::InvocationItem::Event(rustscript::Value::Bool( + true, + ))); + + fn accept_poll(_poll: rustscript::InvocationPoll) {} + accept_poll(rustscript::InvocationPoll::Pending); + accept_poll(rustscript::InvocationPoll::Ready(None)); + accept_poll(rustscript::InvocationPoll::Ready(Some(Ok( + rustscript::InvocationItem::Complete(rustscript::Value::Null), + )))); + + fn accept_error(_error: rustscript::InvocationError) {} + accept_error(rustscript::InvocationError::Cancelled( + rustscript::operation::OperationCancelReason::Requested, + )); + accept_error(rustscript::InvocationError::Host { + message: "boom".to_string(), + }); +} diff --git a/docs/callable-runtime.md b/docs/callable-runtime.md index ed0cfd4b..57319973 100644 --- a/docs/callable-runtime.md +++ b/docs/callable-runtime.md @@ -43,6 +43,18 @@ Reset clears Program runtime values and rebinds root function items from Program PDRC recordings preserve full execution-frame metadata. Callable environments use identity-table encoding, so aliases still share one environment after decode. +## Invocation item stream + +`Vm::start_invocation` starts one exported callable with ordinary `Value` arguments and returns an `Invocation` handle that behaves like a fused `Stream>`: + +- `InvocationItem::Event(value)` items arrive in order for each `stream::emit(value)` call; `stream::emit` still evaluates to `()` inside RSS. +- exactly one `InvocationItem::Complete(value)` carries the callable return value; events never replace it; +- cancellation, fuel exhaustion, epoch deadline expiry, runtime capability failures (including event payload bound violations), and host failures each produce exactly one typed `InvocationError` item; +- every poll after `Complete` or the error item returns `Ready(None)` (fused end of stream); +- `InvocationPoll::Pending` means the VM is paused on an outstanding host operation; drive it through the embedding-owned async bridge and poll again. + +Polling drives execution and provides backpressure: at most one event item is buffered between polls, and the VM does not produce items while the consumer is not polling. `stream::emit` validates only the configured per-item value bound (payload bytes and nesting depth); sequence assignment, receipts, persistence, and delivery policy belong to the embedding. At most one invocation is active per VM, `Invocation::cancel(reason)` cancels with a typed `OperationCancelReason`, dropping the handle retires the invocation synchronously for immediate VM reuse, and the low-level `Vm::run` pump is unchanged for custom drivers. VM reset uses the generic execution-scope close boundary; a pending close keeps the old scope installed and blocks reuse until `poll_reset_for_reuse` reports quiescence. + ## Optimized backends Whole-program AOT and Trace JIT use the same builtin call path (static catalog IDs) for environment binding and native frame dispatch for `callvalue`. Script-frame entry and return preserve frame-relative locals and typed continuations. diff --git a/examples/collection_rebind_bench.rs b/examples/collection_rebind_bench.rs index f954e6c2..28beea68 100644 --- a/examples/collection_rebind_bench.rs +++ b/examples/collection_rebind_bench.rs @@ -222,7 +222,7 @@ fn measure( let mut samples = Vec::with_capacity(config.samples); let mut generic_builtin_calls = 0u64; for _ in 0..config.samples { - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); let native_execs_before_sample = vm.jit_native_exec_count(); let started = Instant::now(); let status = vm diff --git a/examples/mini_bench.rs b/examples/mini_bench.rs index 379f25da..b587725e 100644 --- a/examples/mini_bench.rs +++ b/examples/mini_bench.rs @@ -569,7 +569,7 @@ fn measure_runtime_mode( let mut vm = Vm::new(program.clone()); configure_vm_for_mode(&mut vm, mode); warm_vm_for_mode(&mut vm, mode, expected_stack)?; - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); let started = Instant::now(); let status = vm .run() diff --git a/pd-host-function/Cargo.toml b/pd-host-function/Cargo.toml index cb9c15e7..ea812790 100644 --- a/pd-host-function/Cargo.toml +++ b/pd-host-function/Cargo.toml @@ -11,6 +11,11 @@ repository = "https://github.com/rustscript-lang/rustscript" proc-macro = true [dependencies] +pd-host-schema = { path = "../crates/pd-host-schema", version = "0.1.0" } proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full"] } + +[dev-dependencies] +trybuild = "1" +vm = { package = "pd-vm", path = "..", default-features = false, features = ["runtime"] } diff --git a/pd-host-function/src/lib.rs b/pd-host-function/src/lib.rs index 5c4abaeb..fc86804a 100644 --- a/pd-host-function/src/lib.rs +++ b/pd-host-function/src/lib.rs @@ -5,6 +5,10 @@ use syn::{ punctuated::Punctuated, }; +use pd_host_schema::{ + ResourceMode, ResourceReturnKind, ResourceSpec, borrowed_resource_return, resource_spec, +}; + #[proc_macro_attribute] pub fn pd_host_function(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr with Punctuated::::parse_terminated); @@ -19,10 +23,47 @@ fn expand_pd_host_function( mut item: ItemFn, ) -> Result { parse_name_arg(&attr)?; + let is_async = item.sig.asyncness.is_some(); let docs = doc_string(&item.attrs); + let mut resource_params = Vec::<(String, ResourceSpec)>::new(); for input in &item.sig.inputs { - validate_param(input)?; + let is_host_context = is_host_context_param(input); + if !is_host_context && !is_vm_context_param(input) { + let FnArg::Typed(pat_type) = input else { + return Err(Error::new_spanned(input, "methods are not supported")); + }; + let spec = resource_spec(&pat_type.ty, &pat_type.attrs) + .map_err(|message| Error::new_spanned(&pat_type.ty, message))?; + if let Some(spec) = spec { + if is_async && !matches!(spec.mode, ResourceMode::TakeOwned) { + return Err(Error::new_spanned( + &pat_type.ty, + "resource borrows cannot cross async/yield; only TakeOwned may move into an owned operation", + )); + } + let Pat::Ident(PatIdent { ident, .. }) = pat_type.pat.as_ref() else { + return Err(Error::new_spanned( + &pat_type.pat, + "resource parameters must use identifier patterns", + )); + }; + resource_params.push((ident.to_string(), spec)); + continue; + } + } + if is_async { + validate_async_param(input)?; + } else if is_host_context_param(input) { + return Err(Error::new_spanned( + input, + "#[pd_host_context] is only valid on async host functions", + )); + } + if !is_host_context_param(input) && !is_vm_context_param(input) { + validate_param(input)?; + } } + validate_sync_vm_resource_borrow_conflict(&item, &resource_params)?; validate_return_type(&item.sig.output)?; if is_abi_declaration_only(&item) { @@ -39,13 +80,125 @@ fn expand_pd_host_function( if item.sig.ident != impl_name { item.sig.ident = impl_name.clone(); } - let wrapper = generate_vm_wrapper(&item, &wrapper_name)?; + let wrapper = if is_async { + generate_async_vm_wrapper(&item, &wrapper_name, &resource_params)? + } else { + generate_vm_wrapper(&item, &wrapper_name, &resource_params)? + }; + for input in &mut item.sig.inputs { + if let FnArg::Typed(pat_type) = input { + pat_type.attrs.retain(|attr| { + !matches!( + attr.path() + .get_ident() + .map(syn::Ident::to_string) + .as_deref(), + Some( + "pd_host_context" + | "pd_host_param" + | "pd_host_resource" + | "pd_host_passing" + | "pd_borrow" + | "pd_borrow_mut" + | "pd_take_owned" + | "pd_value" + ) + ) + }); + } + } Ok(quote! { #item #wrapper }) } +fn is_vm_context_param(arg: &FnArg) -> bool { + match arg { + FnArg::Typed(pat_type) => is_vm_context_type(&pat_type.ty), + FnArg::Receiver(_) => false, + } +} + +fn is_mut_vm_context_param(arg: &FnArg) -> bool { + match arg { + FnArg::Typed(pat_type) => is_mut_vm_context_type(&pat_type.ty), + FnArg::Receiver(_) => false, + } +} + +fn is_mut_vm_context_type(ty: &Type) -> bool { + match ty { + Type::Group(group) => is_mut_vm_context_type(&group.elem), + Type::Paren(paren) => is_mut_vm_context_type(&paren.elem), + Type::Reference(reference) => { + reference.mutability.is_some() && is_vm_context_type(&reference.elem) + } + _ => false, + } +} + +fn validate_async_param(arg: &FnArg) -> Result<(), Error> { + let FnArg::Typed(pat_type) = arg else { + return Err(Error::new_spanned(arg, "methods are not supported")); + }; + if is_vm_context_type(&pat_type.ty) { + return Err(Error::new_spanned( + &pat_type.ty, + "async host functions cannot borrow Vm; capture owned host context before submission", + )); + } + if is_host_context_param(arg) { + return Ok(()); + } + if !is_async_owned_type(&pat_type.ty) { + return Err(Error::new_spanned( + &pat_type.ty, + "async host function parameters must be owned and 'static", + )); + } + Ok(()) +} + +fn is_host_context_param(arg: &FnArg) -> bool { + match arg { + FnArg::Typed(pat_type) => pat_type + .attrs + .iter() + .any(|attr| attr.path().is_ident("pd_host_context")), + FnArg::Receiver(_) => false, + } +} + +fn is_async_owned_type(ty: &Type) -> bool { + match ty { + Type::Group(group) => is_async_owned_type(&group.elem), + Type::Paren(paren) => is_async_owned_type(&paren.elem), + Type::Reference(_) | Type::Slice(_) => false, + Type::Tuple(tuple) => tuple.elems.iter().all(is_async_owned_type), + Type::Path(path) => { + let Some(segment) = path.path.segments.last() else { + return false; + }; + if matches!( + segment.ident.to_string().as_str(), + "str" | "VmStringRef" | "VmBytesRef" | "VmArrayRef" | "VmMapRef" | "VmValueRef" + ) { + return false; + } + match &segment.arguments { + syn::PathArguments::None => true, + syn::PathArguments::AngleBracketed(args) => args.args.iter().all(|arg| match arg { + syn::GenericArgument::Type(inner) => is_async_owned_type(inner), + _ => false, + }), + syn::PathArguments::Parenthesized(_) => false, + } + } + _ => false, + } +} + fn parse_name_arg(args: &Punctuated) -> Result { let Some(Meta::NameValue(name_value)) = args.first() else { return Err(Error::new( @@ -123,14 +276,80 @@ fn validate_param(arg: &FnArg) -> Result<(), Error> { "callable parameters must use identifier patterns", )); }; + if resource_spec(&pat_type.ty, &pat_type.attrs) + .map_err(|message| Error::new_spanned(&pat_type.ty, message))? + .is_some() + { + return Ok(()); + } type_label(&pat_type.ty)?; Ok(()) } +fn validate_sync_vm_resource_borrow_conflict( + item: &ItemFn, + resource_params: &[(String, ResourceSpec)], +) -> Result<(), Error> { + if item.sig.asyncness.is_some() || !item.sig.inputs.iter().any(is_mut_vm_context_param) { + return Ok(()); + } + + for input in &item.sig.inputs { + let FnArg::Typed(pat_type) = input else { + continue; + }; + let Pat::Ident(PatIdent { ident, .. }) = pat_type.pat.as_ref() else { + continue; + }; + let Some((_, spec)) = resource_params + .iter() + .find(|(name, _)| name == &ident.to_string()) + else { + continue; + }; + if matches!(spec.mode, ResourceMode::Borrow | ResourceMode::BorrowMut) { + return Err(Error::new_spanned( + &pat_type.ty, + "synchronous host functions cannot combine `&mut Vm` with borrowed resource parameters (`ResourceRef`/`ResourceMut`); generated HostContext holds the same mutable VM borrow", + )); + } + } + Ok(()) +} + fn validate_return_type(output: &ReturnType) -> Result<(), Error> { match output { ReturnType::Default => Ok(()), ReturnType::Type(_, ty) => { + if let Some(found) = borrowed_resource_return(ty) { + let resource_name = match found.kind { + ResourceReturnKind::Borrow => "ResourceRef", + ResourceReturnKind::BorrowMut => "ResourceMut", + ResourceReturnKind::Owned => unreachable!( + "borrowed_resource_return only returns borrowed resource wrappers" + ), + }; + if found.wrappers.is_empty() { + return Err(Error::new_spanned( + ty, + format!( + "{resource_name} cannot be a host function return; resource borrows cannot cross the host boundary" + ), + )); + } + let wrappers = found + .wrappers + .iter() + .map(|wrapper| format!("`{wrapper}`")) + .collect::>() + .join(" -> "); + return Err(Error::new_spanned( + ty, + format!( + "{resource_name} cannot appear in a host function return nested inside {wrappers}; resource borrows cannot cross the host boundary" + ), + )); + } type_label(ty)?; Ok(()) } @@ -153,6 +372,7 @@ fn is_abi_declaration_only(item: &ItemFn) -> bool { fn generate_vm_wrapper( item: &ItemFn, wrapper_name: &syn::Ident, + resource_params: &[(String, ResourceSpec)], ) -> Result { let impl_name = &item.sig.ident; let mut wrapper_params = Vec::::new(); @@ -164,9 +384,12 @@ fn generate_vm_wrapper( FnArg::Typed(pat_type) => is_vm_context_type(&pat_type.ty), FnArg::Receiver(_) => false, }); - if has_vm { + let needs_vm = has_vm || !resource_params.is_empty(); + if needs_vm { wrapper_params.push(quote!(vm: &mut super::super::Vm)); - call_args.push(quote!(vm)); + if has_vm { + call_args.push(quote!(vm)); + } } let imm_wrapper_params = { let mut params = wrapper_params.clone(); @@ -194,8 +417,19 @@ fn generate_vm_wrapper( )); }; let ty = &pat_type.ty; + if let Some((_, spec)) = resource_params + .iter() + .find(|(name, _)| name == &ident.to_string()) + { + let extract = resource_extract_tokens(&ident.to_string(), spec, arg_index)?; + imm_extract_stmts.push(extract.clone()); + mut_extract_stmts.push(extract); + call_args.push(quote!(#ident)); + arg_index += 1; + continue; + } let label = LitStr::new( - &format!("{} {}", wrapper_name, ident), + &format!("{} {ident}", wrapper_name), proc_macro2::Span::call_site(), ); let index = syn::Index::from(arg_index); @@ -236,6 +470,228 @@ fn generate_vm_wrapper( }) } +/// Generates the extraction statement for one resource parameter. +/// +/// The guest passes the raw handle as a signed integer; the wrapper decodes +/// it through the public host-context SDK and re-validates it against the +/// current execution scope before handing the typed token / borrow to the +/// impl. `TakeOwned` removes the value from the table exactly once and wraps +/// it in `ResourceOwned` when that canonical parameter type is used; +/// `Borrow`/`BorrowMut` hand call-scoped borrows. +fn resource_extract_tokens( + ident: &str, + spec: &ResourceSpec, + arg_index: usize, +) -> Result { + let ident = syn::Ident::new(ident, proc_macro2::Span::call_site()); + let inner = &spec.inner; + let index = syn::Index::from(arg_index); + let handle_label = LitStr::new("resource handle", proc_macro2::Span::call_site()); + let key_ident = syn::Ident::new( + &format!("__pd_resource_key_{ident}"), + proc_macro2::Span::call_site(), + ); + let key_validation = spec.key.as_ref().map(|key| { + let key = LitStr::new(key.as_str(), proc_macro2::Span::call_site()); + quote! { + let #key_ident = super::super::host_api::ResourceTypeKey::new(#key) + .map_err(|error| super::super::VmError::HostError(error.to_string()))?; + super::super::resource::ResourceTable::validate_concrete_resource_type_key::<#inner>( + &#key_ident, + ) + .map_err(|error| super::super::VmError::HostError(error.to_string()))?; + } + }); + + let borrow_call = if spec.key.is_some() { + quote! { + .borrow_resource_with_key::<#inner>(handle, &#key_ident) + } + } else { + quote! { + .borrow_resource::<#inner>(handle) + } + }; + let borrow_mut_call = if spec.key.is_some() { + quote! { + .borrow_resource_mut_with_key::<#inner>(handle, &#key_ident) + } + } else { + quote! { + .borrow_resource_mut::<#inner>(handle) + } + }; + let take_call = if spec.key.is_some() { + quote! { + .take_resource_with_key::<#inner>(handle, &#key_ident) + } + } else { + quote! { + .take_resource::<#inner>(handle) + } + }; + + let decode_handle = quote! { + let raw = super::arg::(args, #index, #handle_label)?; + let handle = super::super::resource::ResourceHandle::from_raw(raw as u64) + .map_err(|error| super::super::VmError::HostError(error.to_string()))?; + }; + let context_ident = syn::Ident::new( + &format!("__pd_resource_context_{ident}"), + proc_macro2::Span::call_site(), + ); + let extraction = match spec.mode { + ResourceMode::Borrow => quote! { + #key_validation + #decode_handle + let #context_ident = vm.host_context(); + let #ident = #context_ident + #borrow_call + .map_err(|error| super::super::VmError::HostError(error.to_string()))?; + }, + ResourceMode::BorrowMut => quote! { + #key_validation + #decode_handle + let mut #context_ident = vm.host_context(); + let #ident = #context_ident + #borrow_mut_call + .map_err(|error| super::super::VmError::HostError(error.to_string()))?; + }, + ResourceMode::TakeOwned => { + let owned_value = quote! { + vm + .host_context() + #take_call + .map_err(|error| super::super::VmError::HostError(error.to_string()))? + }; + if spec.owned_wrapper { + quote! { + #key_validation + #decode_handle + let #ident = super::super::resource::ResourceOwned::new(#owned_value); + } + } else { + quote! { + #key_validation + #decode_handle + let #ident = #owned_value; + } + } + } + ResourceMode::Value => { + return Err(Error::new( + proc_macro2::Span::call_site(), + "resource-containing Value parameters are rejected; use Borrow, BorrowMut, or TakeOwned", + )); + } + }; + Ok(extraction) +} + +fn generate_async_vm_wrapper( + item: &ItemFn, + wrapper_name: &syn::Ident, + resource_params: &[(String, ResourceSpec)], +) -> Result { + let impl_name = &item.sig.ident; + let mutable_wrapper_name = syn::Ident::new(&format!("{wrapper_name}_mut"), wrapper_name.span()); + let mut extract_stmts = Vec::::new(); + let mut call_args = Vec::::new(); + let mut arg_index = 0usize; + + for input in &item.sig.inputs { + let FnArg::Typed(pat_type) = input else { + return Err(Error::new_spanned(input, "methods are not supported")); + }; + let Pat::Ident(PatIdent { ident, .. }) = pat_type.pat.as_ref() else { + return Err(Error::new_spanned( + &pat_type.pat, + "callable parameters must use identifier patterns", + )); + }; + let ty = &pat_type.ty; + if is_host_context_param(input) { + extract_stmts.push(quote! { + let #ident = <#ty as super::CaptureAsyncHostContext>::capture_with_args(vm, args)?; + }); + call_args.push(quote!(#ident)); + continue; + } + if let Some((_, spec)) = resource_params + .iter() + .find(|(name, _)| name == &ident.to_string()) + { + // Only TakeOwned may move into an owned operation; the typed token + // is captured before the future is submitted. + let extract = resource_extract_tokens(&ident.to_string(), spec, arg_index)?; + extract_stmts.push(extract); + call_args.push(quote!(#ident)); + arg_index += 1; + continue; + } + let label = LitStr::new( + &format!("{} {ident}", wrapper_name), + proc_macro2::Span::call_site(), + ); + let index = syn::Index::from(arg_index); + extract_stmts.push(quote! { + let #ident = super::borrow_arg::<#ty>(args, #index, #label)?; + }); + call_args.push(quote!(#ident)); + arg_index += 1; + } + + let await_value = if return_is_vm_result(&item.sig.output) { + quote!(#impl_name(#(#call_args),*).await?) + } else { + quote!(#impl_name(#(#call_args),*).await) + }; + let future_result = if return_is_host_future_output(&item.sig.output) { + quote!(Ok(value.map(super::return_one))) + } else { + quote! { + match super::IntoHostCallOutcome::into_host_call_outcome(value) { + super::CallOutcome::Return(values) => { + Ok(super::HostFutureOutput::returning(values)) + } + super::CallOutcome::Pending(op_id) => Err(super::VmError::HostError( + format!("async host function returned nested pending operation {op_id}"), + )), + super::CallOutcome::Halt | super::CallOutcome::Yield => Err( + super::VmError::HostError( + "async host function returned a control-flow outcome".to_string(), + ), + ), + } + } + }; + let body = quote! { + #(#extract_stmts)* + vm.submit_host_future(Box::pin(async move { + let value = #await_value; + #future_result + })) + }; + + Ok(quote! { + #[allow(dead_code)] + pub(crate) fn #wrapper_name( + vm: &mut super::super::Vm, + args: &[super::super::Value], + ) -> super::super::VmResult { + #body + } + + #[allow(dead_code)] + pub(crate) fn #mutable_wrapper_name( + vm: &mut super::super::Vm, + args: &mut [super::super::Value], + ) -> super::super::VmResult { + #body + } + }) +} + fn wrapper_and_impl_names(name: &syn::Ident) -> (syn::Ident, syn::Ident) { let original = name.to_string(); match original.strip_suffix("_impl") { @@ -304,142 +760,22 @@ fn return_is_vm_result(output: &ReturnType) -> bool { .is_some() } -fn type_label(ty: &Type) -> Result { - match ty { - Type::Group(group) => type_label(&group.elem), - Type::Paren(paren) => type_label(&paren.elem), - Type::Reference(reference) => type_label(&reference.elem), - Type::Slice(slice) => match slice.elem.as_ref() { - Type::Path(path) => { - let Some(segment) = path.path.segments.last() else { - return Err(Error::new_spanned(slice, "unsupported callable type")); - }; - if segment.ident == "u8" { - Ok("bytes".to_string()) - } else { - Err(Error::new_spanned(slice, "unsupported callable type")) - } - } - _ => Err(Error::new_spanned(slice, "unsupported callable type")), - }, - Type::Tuple(tuple) if tuple.elems.is_empty() => Ok("null".to_string()), - Type::Path(path) => { - let Some(segment) = path.path.segments.last() else { - return Err(Error::new_spanned(path, "unsupported callable type")); - }; - let ident = segment.ident.to_string(); - match ident.as_str() { - "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" - | "u128" | "usize" => Ok("int".to_string()), - "f32" | "f64" => Ok("float".to_string()), - "bool" => Ok("bool".to_string()), - "String" | "str" | "VmStringRef" => Ok("string".to_string()), - "Bytes" | "VmBytes" | "VmBytesRef" | "VmBytesHandle" => Ok("bytes".to_string()), - "Any" | "AnyValue" | "Value" | "VmValueRef" | "VmValueOwned" => { - Ok("any".to_string()) - } - "Array" | "VmArray" | "VmArrayRef" | "VmArrayHandle" => Ok("array".to_string()), - "Map" | "VmMap" | "VmMapRef" | "VmMapHandle" => Ok("map".to_string()), - "Number" | "NumberValue" => Ok("number".to_string()), - "Unknown" | "UnknownValue" => Ok("unknown".to_string()), - "CallOutcome" => Ok("unknown".to_string()), - "Option" => { - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - return Err(Error::new_spanned( - &segment.arguments, - "Option requires one generic argument", - )); - }; - let Some(syn::GenericArgument::Type(inner)) = args.args.first() else { - return Err(Error::new_spanned( - args, - "Option requires one type argument", - )); - }; - let inner_label = type_label(inner)?; - Ok(format!("{inner_label} | null")) - } - "VmResult" | "HostCallResult" => { - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - return Err(Error::new_spanned( - &segment.arguments, - format!("{ident} requires one generic argument"), - )); - }; - let Some(syn::GenericArgument::Type(inner)) = args.args.first() else { - return Err(Error::new_spanned( - args, - format!("{ident} requires one type argument"), - )); - }; - type_label(inner) - } - "Vec" => type_label_for_vec(segment), - _ => Err(Error::new_spanned( - path, - format!("unsupported callable type '{ident}'"), - )), - } - } - _ => Err(Error::new_spanned(ty, "unsupported callable type")), - } -} - -fn type_label_for_vec(segment: &syn::PathSegment) -> Result { - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - return Err(Error::new_spanned( - &segment.arguments, - "Vec requires one generic argument", - )); - }; - let Some(syn::GenericArgument::Type(inner)) = args.args.first() else { - return Err(Error::new_spanned( - args, - "Vec requires one type argument", - )); - }; - match inner { - Type::Tuple(tuple) if tuple.elems.len() == 2 => { - let lhs = tuple - .elems - .first() - .expect("tuple should contain first element"); - let rhs = tuple - .elems +fn return_is_host_future_output(output: &ReturnType) -> bool { + vm_result_inner_type(output) + .expect("pd_host_function return type should already be validated") + .and_then(|ty| match ty { + Type::Path(path) => path + .path + .segments .last() - .expect("tuple should contain second element"); - if is_value_type(lhs) && is_value_type(rhs) { - Ok("map".to_string()) - } else { - Err(Error::new_spanned( - inner, - "unsupported Vec tuple type in callable metadata", - )) - } - } - _ if is_value_type(inner) => Ok("array".to_string()), - _ => { - let inner_label = type_label(inner)?; - Err(Error::new_spanned( - inner, - format!("unsupported Vec return type '{inner_label}'"), - )) - } - } + .map(|segment| segment.ident.clone()), + _ => None, + }) + .is_some_and(|ident| ident == "HostFutureOutput") } -fn is_value_type(ty: &Type) -> bool { - match ty { - Type::Group(group) => is_value_type(&group.elem), - Type::Paren(paren) => is_value_type(&paren.elem), - Type::Reference(reference) => is_value_type(&reference.elem), - Type::Path(path) => path - .path - .segments - .last() - .is_some_and(|segment| segment.ident == "Value"), - _ => false, - } +fn type_label(ty: &Type) -> Result { + pd_host_schema::type_label(ty).map_err(|message| Error::new_spanned(ty, message)) } fn is_vm_context_type(ty: &Type) -> bool { @@ -482,8 +818,8 @@ fn uses_taken_extractor(ty: &Type) -> bool { #[cfg(test)] mod tests { - use super::expand_pd_host_function; - use syn::{ItemFn, Meta, Token, parse_quote, punctuated::Punctuated}; + use super::{expand_pd_host_function, type_label}; + use syn::{ItemFn, Meta, Token, Type, parse_quote, punctuated::Punctuated}; #[test] fn accepts_host_call_result_from_the_function_signature() { @@ -533,4 +869,199 @@ mod tests { .expect_err("the pd-host-function macro must not accept an async attribute"); assert!(error.to_string().contains("only supports name")); } + + #[test] + fn ordinary_async_signature_generates_host_driven_future_submission() { + let attr: Punctuated = parse_quote!(name = "test::async_call"); + let item: ItemFn = parse_quote!( + /// Returns an owned string asynchronously. + async fn async_call( + #[pd_host_context] context: TestContext, + value: String, + ) -> VmResult { + context.run(value).await + } + ); + let expanded = expand_pd_host_function(attr, item) + .expect("ordinary owned async function should use the generic async host contract") + .to_string(); + assert!(expanded.contains("submit_host_future")); + assert!(expanded.contains("async move")); + assert!(expanded.contains("borrow_arg")); + assert!(expanded.contains("CaptureAsyncHostContext")); + assert!(expanded.contains("capture_with_args")); + assert!(!expanded.contains("pd_host_context")); + } + + #[test] + fn async_host_future_output_maps_its_inner_value_to_call_return() { + let attr: Punctuated = parse_quote!(name = "test::completion"); + let item: ItemFn = parse_quote! { + /// Completes after mutating VM-owned state. + async fn completion() -> VmResult> { + todo!() + } + }; + + let expanded = expand_pd_host_function(attr, item) + .expect("host future output should be accepted") + .to_string(); + assert!(expanded.contains("value . map (super :: return_one)")); + } + + #[test] + fn async_signature_rejects_borrowed_parameters() { + let attr: Punctuated = parse_quote!(name = "test::borrowed"); + let item: ItemFn = parse_quote! { + async fn borrowed(value: &str) -> VmResult { + Ok(value.to_string()) + } + }; + + let error = expand_pd_host_function(attr, item).expect_err("borrow should be rejected"); + assert!( + error + .to_string() + .contains("parameters must be owned and 'static") + ); + } + + #[test] + fn callable_wrapper_preserves_parameter_and_result_schema() { + let ty: Type = parse_quote!(VmCallable VmMap>); + assert_eq!(type_label(&ty).unwrap(), "fn(map) -> map"); + let attr: Punctuated = parse_quote!(name = "test::stream"); + let item: ItemFn = parse_quote! { + /// Starts a synthetic callable stream. + fn stream(callback: VmCallable VmMap>) -> VmResult { + todo!() + } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("VmCallable < fn (VmMap) -> VmMap >")); + assert!(expanded.contains("borrow_arg")); + + let float_ty: Type = parse_quote!(VmCallable f64>); + assert_eq!(type_label(&float_ty).unwrap(), "fn(float) -> float"); + } + + #[test] + fn take_owned_resource_param_generates_owned_extraction() { + let attr: Punctuated = parse_quote!(name = "test::use_counter"); + let item: ItemFn = parse_quote! { + /// Reads a counter resource by owned value. + fn use_counter( + #[pd_host_resource(passing = "take_owned", key = "demo.counter")] + counter: ResourceOwned, + ) -> VmResult { + todo!() + } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("take_resource")); + assert!(expanded.contains("ResourceOwned :: new")); + assert!(expanded.contains("ResourceHandle :: from_raw")); + assert!(expanded.contains("host_context")); + } + + #[test] + fn resource_parameter_adds_vm_to_wrapper_and_preserves_shared_mode() { + let attr: Punctuated = parse_quote!(name = "test::peek_counter"); + let item: ItemFn = parse_quote! { + /// Peeks a counter resource by immutable borrow. + fn peek_counter( + #[pd_host_resource(passing = "borrow", key = "demo.counter")] + counter: ResourceRef<'_, Counter>, + ) -> VmResult { + todo!() + } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("vm : & mut super :: super :: Vm")); + assert!(expanded.contains("borrow_resource")); + assert!(expanded.contains("ResourceHandle :: from_raw")); + } + + #[test] + fn borrow_resource_with_vm_param_is_rejected_before_generation() { + let attr: Punctuated = parse_quote!(name = "test::peek_counter"); + let item: ItemFn = parse_quote! { + /// A borrowed resource cannot share the mutable VM parameter. + fn peek_counter( + vm: &mut Vm, + #[pd_host_resource(passing = "borrow", key = "demo.counter")] + counter: ResourceRef<'_, Counter>, + ) -> VmResult { + todo!() + } + }; + let error = expand_pd_host_function(attr, item) + .expect_err("a mutable VM and borrowed resource cannot share a wrapper"); + assert!(error.to_string().contains("cannot combine")); + assert!(error.to_string().contains("HostContext")); + } + + #[test] + fn borrow_mut_resource_param_generates_mut_borrow_extraction() { + let attr: Punctuated = parse_quote!(name = "test::bump_counter"); + let item: ItemFn = parse_quote! { + /// Bumps a counter resource by mutable borrow. + fn bump_counter( + #[pd_host_resource(passing = "borrow_mut", key = "demo.counter")] + counter: ResourceMut<'_, Counter>, + ) -> VmResult { + todo!() + } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("borrow_resource_mut")); + } + + #[test] + fn resource_value_passing_rejected() { + let attr: Punctuated = parse_quote!(name = "test::bad_value"); + let item: ItemFn = parse_quote! { + /// A resource passed by value must be rejected. + fn bad_value( + #[pd_host_resource(passing = "value", key = "demo.counter")] + counter: Resource, + ) -> VmResult { + todo!() + } + }; + let error = expand_pd_host_function(attr, item) + .expect_err("resource Value passing must be rejected"); + assert!(error.to_string().contains("Value")); + } + + #[test] + fn async_borrow_resource_rejected() { + let attr: Punctuated = parse_quote!(name = "test::async_borrow"); + let item: ItemFn = parse_quote! { + /// A borrowed resource cannot cross an async boundary. + async fn async_borrow( + #[pd_host_resource(passing = "borrow", key = "demo.counter")] + counter: ResourceRef<'_, Counter>, + ) -> VmResult { + todo!() + } + }; + let error = expand_pd_host_function(attr, item) + .expect_err("async resource borrows must be rejected"); + assert!(error.to_string().contains("cannot cross async")); + } + + #[test] + fn resource_ref_return_rejected() { + let attr: Punctuated = parse_quote!(name = "test::bad_return"); + let item: ItemFn = parse_quote! { + /// A resource borrow return must be rejected. + fn bad_return(value: i64) -> ResourceRef<'_, Counter> { + todo!() + } + }; + let error = + expand_pd_host_function(attr, item).expect_err("ResourceRef return must be rejected"); + assert!(error.to_string().contains("ResourceRef")); + } } diff --git a/pd-host-function/tests/compile_fail.rs b/pd-host-function/tests/compile_fail.rs new file mode 100644 index 00000000..3e99d597 --- /dev/null +++ b/pd-host-function/tests/compile_fail.rs @@ -0,0 +1,11 @@ +#[test] +fn invalid_resource_signatures_emit_macro_diagnostics() { + let cases = trybuild::TestCases::new(); + cases.compile_fail("tests/ui/fail/*.rs"); +} + +#[test] +fn valid_resource_signatures_compile_against_public_vm_api() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass/*.rs"); +} diff --git a/pd-host-function/tests/ui/fail/async_borrow_resource_mut.rs b/pd-host-function/tests/ui/fail/async_borrow_resource_mut.rs new file mode 100644 index 00000000..b2736f9c --- /dev/null +++ b/pd-host-function/tests/ui/fail/async_borrow_resource_mut.rs @@ -0,0 +1,26 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceMut; +use vm::VmResult; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// A mutable resource borrow cannot cross an async boundary. + #[pd_host_function(name = "test::async_borrow_resource_mut")] + async fn async_borrow_resource_mut( + counter: ResourceMut<'_, Counter>, + ) -> VmResult { + let _ = counter; + Ok(0) + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/async_borrow_resource_mut.stderr b/pd-host-function/tests/ui/fail/async_borrow_resource_mut.stderr new file mode 100644 index 00000000..a2e668b7 --- /dev/null +++ b/pd-host-function/tests/ui/fail/async_borrow_resource_mut.stderr @@ -0,0 +1,5 @@ +error: resource borrows cannot cross async/yield; only TakeOwned may move into an owned operation + --> tests/ui/fail/async_borrow_resource_mut.rs:18:22 + | +18 | counter: ResourceMut<'_, Counter>, + | ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/async_borrow_resource_ref.rs b/pd-host-function/tests/ui/fail/async_borrow_resource_ref.rs new file mode 100644 index 00000000..d57863fa --- /dev/null +++ b/pd-host-function/tests/ui/fail/async_borrow_resource_ref.rs @@ -0,0 +1,26 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceRef; +use vm::VmResult; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// A shared resource borrow cannot cross an async boundary. + #[pd_host_function(name = "test::async_borrow_resource_ref")] + async fn async_borrow_resource_ref( + counter: ResourceRef<'_, Counter>, + ) -> VmResult { + let _ = counter; + Ok(0) + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/async_borrow_resource_ref.stderr b/pd-host-function/tests/ui/fail/async_borrow_resource_ref.stderr new file mode 100644 index 00000000..0e6b8c2d --- /dev/null +++ b/pd-host-function/tests/ui/fail/async_borrow_resource_ref.stderr @@ -0,0 +1,5 @@ +error: resource borrows cannot cross async/yield; only TakeOwned may move into an owned operation + --> tests/ui/fail/async_borrow_resource_ref.rs:18:22 + | +18 | counter: ResourceRef<'_, Counter>, + | ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/async_vm_resource_owned.rs b/pd-host-function/tests/ui/fail/async_vm_resource_owned.rs new file mode 100644 index 00000000..3e62e3c3 --- /dev/null +++ b/pd-host-function/tests/ui/fail/async_vm_resource_owned.rs @@ -0,0 +1,27 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceOwned; +use vm::{Vm, VmResult}; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// Async host functions cannot borrow the VM while submitting a future. + #[pd_host_function(name = "test::async_vm_resource_owned")] + async fn async_vm_resource_owned( + vm: &mut Vm, + counter: ResourceOwned, + ) -> VmResult { + let _ = (vm, counter); + Ok(0) + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/async_vm_resource_owned.stderr b/pd-host-function/tests/ui/fail/async_vm_resource_owned.stderr new file mode 100644 index 00000000..98310812 --- /dev/null +++ b/pd-host-function/tests/ui/fail/async_vm_resource_owned.stderr @@ -0,0 +1,5 @@ +error: async host functions cannot borrow Vm; capture owned host context before submission + --> tests/ui/fail/async_vm_resource_owned.rs:18:17 + | +18 | vm: &mut Vm, + | ^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/direct_resource_mut_return.rs b/pd-host-function/tests/ui/fail/direct_resource_mut_return.rs new file mode 100644 index 00000000..2c762d68 --- /dev/null +++ b/pd-host-function/tests/ui/fail/direct_resource_mut_return.rs @@ -0,0 +1,22 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceMut; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// A direct mutable resource borrow cannot be returned by a host function. + #[pd_host_function(name = "test::direct_resource_mut_return")] + fn direct_resource_mut_return() -> ResourceMut<'static, Counter> { + todo!() + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/direct_resource_mut_return.stderr b/pd-host-function/tests/ui/fail/direct_resource_mut_return.stderr new file mode 100644 index 00000000..b30fce37 --- /dev/null +++ b/pd-host-function/tests/ui/fail/direct_resource_mut_return.stderr @@ -0,0 +1,5 @@ +error: ResourceMut cannot be a host function return; resource borrows cannot cross the host boundary + --> tests/ui/fail/direct_resource_mut_return.rs:16:44 + | +16 | fn direct_resource_mut_return() -> ResourceMut<'static, Counter> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/direct_resource_ref_return.rs b/pd-host-function/tests/ui/fail/direct_resource_ref_return.rs new file mode 100644 index 00000000..b03558cb --- /dev/null +++ b/pd-host-function/tests/ui/fail/direct_resource_ref_return.rs @@ -0,0 +1,23 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceRef; +use vm::VmResult; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// A direct shared resource borrow cannot be returned by a host function. + #[pd_host_function(name = "test::direct_resource_ref_return")] + fn direct_resource_ref_return() -> ResourceRef<'static, Counter> { + todo!() + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/direct_resource_ref_return.stderr b/pd-host-function/tests/ui/fail/direct_resource_ref_return.stderr new file mode 100644 index 00000000..8f7910a9 --- /dev/null +++ b/pd-host-function/tests/ui/fail/direct_resource_ref_return.stderr @@ -0,0 +1,5 @@ +error: ResourceRef cannot be a host function return; resource borrows cannot cross the host boundary + --> tests/ui/fail/direct_resource_ref_return.rs:17:44 + | +17 | fn direct_resource_ref_return() -> ResourceRef<'static, Counter> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/nested_host_future_output_resource.rs b/pd-host-function/tests/ui/fail/nested_host_future_output_resource.rs new file mode 100644 index 00000000..d2fbc426 --- /dev/null +++ b/pd-host-function/tests/ui/fail/nested_host_future_output_resource.rs @@ -0,0 +1,24 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceMut; +use vm::HostFutureOutput; +use vm::VmResult; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// HostFutureOutput is transparent for borrowed-return validation. + #[pd_host_function(name = "test::nested_host_future_output_resource")] + fn nested_host_future_output_resource() -> VmResult>> { + todo!() + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/nested_host_future_output_resource.stderr b/pd-host-function/tests/ui/fail/nested_host_future_output_resource.stderr new file mode 100644 index 00000000..d136eccd --- /dev/null +++ b/pd-host-function/tests/ui/fail/nested_host_future_output_resource.stderr @@ -0,0 +1,5 @@ +error: ResourceMut cannot appear in a host function return nested inside `VmResult` -> `HostFutureOutput`; resource borrows cannot cross the host boundary + --> tests/ui/fail/nested_host_future_output_resource.rs:18:52 + | +18 | fn nested_host_future_output_resource() -> VmResult>> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/nested_result_host_call_resource.rs b/pd-host-function/tests/ui/fail/nested_result_host_call_resource.rs new file mode 100644 index 00000000..60810804 --- /dev/null +++ b/pd-host-function/tests/ui/fail/nested_result_host_call_resource.rs @@ -0,0 +1,24 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceRef; +use vm::{HostCallResult, VmError}; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// Result and HostCallResult are transparent for borrowed-return validation. + #[pd_host_function(name = "test::nested_result_host_call_resource")] + fn nested_result_host_call_resource( + ) -> Result>, VmError> { + todo!() + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/nested_result_host_call_resource.stderr b/pd-host-function/tests/ui/fail/nested_result_host_call_resource.stderr new file mode 100644 index 00000000..a7560951 --- /dev/null +++ b/pd-host-function/tests/ui/fail/nested_result_host_call_resource.stderr @@ -0,0 +1,5 @@ +error: ResourceRef cannot appear in a host function return nested inside `Result` -> `HostCallResult`; resource borrows cannot cross the host boundary + --> tests/ui/fail/nested_result_host_call_resource.rs:18:14 + | +18 | ) -> Result>, VmError> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/nested_vm_result_option_resource.rs b/pd-host-function/tests/ui/fail/nested_vm_result_option_resource.rs new file mode 100644 index 00000000..b42a3645 --- /dev/null +++ b/pd-host-function/tests/ui/fail/nested_vm_result_option_resource.rs @@ -0,0 +1,23 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceRef; +use vm::VmResult; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// A borrowed resource nested inside transparent return wrappers is rejected. + #[pd_host_function(name = "test::nested_vm_result_option_resource")] + fn nested_vm_result_option_resource() -> VmResult>> { + todo!() + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/nested_vm_result_option_resource.stderr b/pd-host-function/tests/ui/fail/nested_vm_result_option_resource.stderr new file mode 100644 index 00000000..4a5bd3cd --- /dev/null +++ b/pd-host-function/tests/ui/fail/nested_vm_result_option_resource.stderr @@ -0,0 +1,5 @@ +error: ResourceRef cannot appear in a host function return nested inside `VmResult` -> `Option`; resource borrows cannot cross the host boundary + --> tests/ui/fail/nested_vm_result_option_resource.rs:17:50 + | +17 | fn nested_vm_result_option_resource() -> VmResult>> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/option_borrowed_mut_resource.rs b/pd-host-function/tests/ui/fail/option_borrowed_mut_resource.rs new file mode 100644 index 00000000..51c780c9 --- /dev/null +++ b/pd-host-function/tests/ui/fail/option_borrowed_mut_resource.rs @@ -0,0 +1,23 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceMut; +use vm::{VmResult, VmError}; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// A borrowed mutable resource nested inside Option is rejected. + #[pd_host_function(name = "test::option_borrowed_mut_resource")] + fn option_borrowed_mut_resource() -> Option> { + todo!() + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/option_borrowed_mut_resource.stderr b/pd-host-function/tests/ui/fail/option_borrowed_mut_resource.stderr new file mode 100644 index 00000000..8ba83a10 --- /dev/null +++ b/pd-host-function/tests/ui/fail/option_borrowed_mut_resource.stderr @@ -0,0 +1,5 @@ +error: ResourceMut cannot appear in a host function return nested inside `Option`; resource borrows cannot cross the host boundary + --> tests/ui/fail/option_borrowed_mut_resource.rs:17:46 + | +17 | fn option_borrowed_mut_resource() -> Option> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/sync_vm_resource_borrow.rs b/pd-host-function/tests/ui/fail/sync_vm_resource_borrow.rs new file mode 100644 index 00000000..f9f3e87f --- /dev/null +++ b/pd-host-function/tests/ui/fail/sync_vm_resource_borrow.rs @@ -0,0 +1,27 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceRef; +use vm::{Vm, VmResult}; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// A shared resource borrow cannot share a mutable VM parameter. + #[pd_host_function(name = "test::sync_vm_resource_borrow")] + fn sync_vm_resource_borrow( + vm: &mut Vm, + counter: ResourceRef<'_, Counter>, + ) -> VmResult { + let _ = (vm, counter); + Ok(0) + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/sync_vm_resource_borrow.stderr b/pd-host-function/tests/ui/fail/sync_vm_resource_borrow.stderr new file mode 100644 index 00000000..9024c927 --- /dev/null +++ b/pd-host-function/tests/ui/fail/sync_vm_resource_borrow.stderr @@ -0,0 +1,5 @@ +error: synchronous host functions cannot combine `&mut Vm` with borrowed resource parameters (`ResourceRef`/`ResourceMut`); generated HostContext holds the same mutable VM borrow + --> tests/ui/fail/sync_vm_resource_borrow.rs:19:22 + | +19 | counter: ResourceRef<'_, Counter>, + | ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/sync_vm_resource_borrow_mut.rs b/pd-host-function/tests/ui/fail/sync_vm_resource_borrow_mut.rs new file mode 100644 index 00000000..ec5151d9 --- /dev/null +++ b/pd-host-function/tests/ui/fail/sync_vm_resource_borrow_mut.rs @@ -0,0 +1,27 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceMut; +use vm::{Vm, VmResult}; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// A mutable resource borrow cannot share a mutable VM parameter. + #[pd_host_function(name = "test::sync_vm_resource_borrow_mut")] + fn sync_vm_resource_borrow_mut( + vm: &mut Vm, + counter: ResourceMut<'_, Counter>, + ) -> VmResult { + let _ = (vm, counter); + Ok(0) + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/sync_vm_resource_borrow_mut.stderr b/pd-host-function/tests/ui/fail/sync_vm_resource_borrow_mut.stderr new file mode 100644 index 00000000..cdd46001 --- /dev/null +++ b/pd-host-function/tests/ui/fail/sync_vm_resource_borrow_mut.stderr @@ -0,0 +1,5 @@ +error: synchronous host functions cannot combine `&mut Vm` with borrowed resource parameters (`ResourceRef`/`ResourceMut`); generated HostContext holds the same mutable VM borrow + --> tests/ui/fail/sync_vm_resource_borrow_mut.rs:19:22 + | +19 | counter: ResourceMut<'_, Counter>, + | ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/vm_result_borrowed_resource.rs b/pd-host-function/tests/ui/fail/vm_result_borrowed_resource.rs new file mode 100644 index 00000000..f59b68d9 --- /dev/null +++ b/pd-host-function/tests/ui/fail/vm_result_borrowed_resource.rs @@ -0,0 +1,23 @@ +#![allow(dead_code, unused_imports)] + +use pd_host_function::pd_host_function; +use vm::resource::ResourceRef; +use vm::VmResult; + +struct Counter; + +mod generated_parent { + use super::*; + + pub mod functions { + use super::*; + + /// A borrowed resource nested inside VmResult is rejected. + #[pd_host_function(name = "test::vm_result_borrowed_resource")] + fn vm_result_borrowed_resource() -> VmResult> { + todo!() + } + } +} + +fn main() {} diff --git a/pd-host-function/tests/ui/fail/vm_result_borrowed_resource.stderr b/pd-host-function/tests/ui/fail/vm_result_borrowed_resource.stderr new file mode 100644 index 00000000..2dfb4d30 --- /dev/null +++ b/pd-host-function/tests/ui/fail/vm_result_borrowed_resource.stderr @@ -0,0 +1,5 @@ +error: ResourceRef cannot appear in a host function return nested inside `VmResult`; resource borrows cannot cross the host boundary + --> tests/ui/fail/vm_result_borrowed_resource.rs:17:45 + | +17 | fn vm_result_borrowed_resource() -> VmResult> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/pass/valid_resource_signatures.rs b/pd-host-function/tests/ui/pass/valid_resource_signatures.rs new file mode 100644 index 00000000..8c1494b4 --- /dev/null +++ b/pd-host-function/tests/ui/pass/valid_resource_signatures.rs @@ -0,0 +1,92 @@ +#![allow(dead_code, unused_imports)] + +extern crate vm as vm_sdk; + +pub mod vm { + pub use super::vm_sdk::*; +} + +use pd_host_function::pd_host_function; +use vm::resource::{CloseProgress, HostResource, ResourceOwned, ResourceRef}; +use vm::resource; +use vm::{Value, Vm, VmError, VmResult}; +pub use vm::host_api; + +#[derive(Debug)] +struct Counter(i64); + +impl HostResource for Counter { + fn begin_close( + &mut self, + _reason: vm::resource::ResourceCloseReason, + ) -> vm::resource::ResourceResult { + Ok(CloseProgress::Ready) + } +} + +mod generated_parent { + use super::*; + + pub trait FromArg: Sized { + fn from_arg(value: &Value, label: &str) -> VmResult; + } + + impl FromArg for i64 { + fn from_arg(_value: &Value, _label: &str) -> VmResult { + Ok(0) + } + } + + pub fn arg(args: &[Value], index: usize, label: &str) -> VmResult { + args.get(index) + .ok_or_else(|| VmError::HostError(format!("missing {label}"))) + .and_then(|value| T::from_arg(value, label)) + } + + pub fn borrow_arg(args: &[Value], index: usize, label: &str) -> VmResult { + arg(args, index, label) + } + + pub mod functions { + use super::*; + + /// Accepts the public ResourceOwned wrapper as a consuming parameter. + #[pd_host_function(name = "test::take_owned_wrapper")] + fn take_owned_wrapper(counter: ResourceOwned) -> VmResult { + let _ = counter; + Ok(0) + } + + /// Accepts a concrete resource through explicit TakeOwned metadata. + #[pd_host_function(name = "test::take_owned_metadata")] + fn take_owned_metadata( + #[pd_host_param(passing = "take_owned")] counter: Counter, + ) -> VmResult { + let _ = counter; + Ok(0) + } + + /// Accepts a shared resource borrow without a conflicting VM borrow. + #[pd_host_function(name = "test::borrow_resource")] + fn borrow_resource(counter: ResourceRef<'_, Counter>) -> VmResult { + let _ = counter; + Ok(0) + } + + /// Combines a mutable VM context with an owned resource safely. + #[pd_host_function(name = "test::vm_and_owned")] + fn vm_and_owned(vm: &mut Vm, counter: ResourceOwned) -> VmResult { + let _ = (vm, counter); + Ok(0) + } + + /// Uses a mutable VM context with an ordinary value parameter. + #[pd_host_function(name = "test::vm_and_value")] + fn vm_and_value(vm: &mut Vm, value: i64) -> VmResult { + let _ = vm; + Ok(value) + } + } +} + +fn main() {} diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index 77d8b9b2..da79f736 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -9,6 +9,7 @@ use super::{ const MAGIC: [u8; 4] = *b"VMBC"; const VERSION_V11: u16 = 11; +const VERSION_V12: u16 = 12; const FLAGS: u16 = 0; const MAX_SCHEMA_DEPTH: usize = 64; const MAX_CONSTANT_DEPTH: usize = 64; @@ -57,9 +58,11 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V11 { - return Err(WireError::UnsupportedVersion(version)); - } + let has_host_import_schemas = match version { + VERSION_V11 => false, + VERSION_V12 => true, + _ => return Err(WireError::UnsupportedVersion(version)), + }; let flags = cursor.read_u16()?; if flags != FLAGS { return Err(WireError::UnsupportedFlags(flags)); @@ -82,6 +85,13 @@ 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)), + } + } } let encoded_local_count = skip_type_map(&mut cursor)?; @@ -163,6 +173,40 @@ fn skip_bool_vector(cursor: &mut Cursor<'_>, expected: usize) -> Result<(), Wire Ok(()) } +fn skip_host_import_schema(cursor: &mut Cursor<'_>) -> Result<(), WireError> { + cursor.skip_string()?; + let parameter_count = cursor.read_u32()? as usize; + for _ in 0..parameter_count { + cursor.skip_string()?; + skip_host_schema(cursor, 0)?; + match cursor.read_u8()? { + 0..=3 => {} + value => return Err(WireError::InvalidValueType(value)), + } + } + skip_host_schema(cursor, 0)?; + cursor.read_exact(8).map(|_| ()) +} + +fn skip_host_schema(cursor: &mut Cursor<'_>, depth: usize) -> Result<(), WireError> { + if depth >= MAX_SCHEMA_DEPTH { + return Err(WireError::SchemaTooDeep); + } + match cursor.read_u8()? { + 0..=7 => Ok(()), + 8..=10 => skip_host_schema(cursor, depth + 1), + 11 => { + let parameter_count = cursor.read_u32()? as usize; + for _ in 0..parameter_count { + skip_host_schema(cursor, depth + 1)?; + } + skip_host_schema(cursor, depth + 1) + } + 12 => cursor.skip_string(), + value => Err(WireError::InvalidValueType(value)), + } +} + fn skip_schema(cursor: &mut Cursor<'_>, depth: usize) -> Result<(), WireError> { if depth >= MAX_SCHEMA_DEPTH { return Err(WireError::SchemaTooDeep); diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index 8bd2b5eb..49e7ca1f 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -4,8 +4,10 @@ use pd_vm_nostd::{ }; use vm::compiler::TypeSchema; use vm::{ - HostImport, OpCode, Program, ReplLocalBinding, Value, ValueType, compile_source, - compile_source_for_repl, compile_source_for_repl_with_locals, encode_program, + HostApiBuilder, HostFunctionSchema, HostImport, HostImportSchema, HostParamPassing, + HostParamSchema, HostTypeSchema, OpCode, Program, ReplLocalBinding, ResourceTypeKey, + ResourceTypeSchema, Value, ValueType, compile_source, compile_source_for_repl, + compile_source_for_repl_with_locals, encode_program, }; fn encoded_scalar_program() -> Vec { @@ -29,9 +31,9 @@ fn encoded_scalar_program() -> Vec { } #[test] -fn embedded_decoder_reads_host_generated_v11() { +fn embedded_decoder_reads_host_generated_v12() { let bytes = encoded_scalar_program(); - let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v11"); + let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v12"); assert_eq!( program.code(), @@ -52,6 +54,54 @@ fn embedded_decoder_reads_host_generated_v11() { assert_eq!(program.imports()[0].arity, 1); } +#[test] +fn embedded_decoder_skips_full_host_schema_metadata() { + let resource = ResourceTypeKey::new("embedded.resource").expect("resource key"); + let function = HostFunctionSchema::with_return( + "embedded::schema", + vec![HostParamSchema::with_passing( + "value", + HostTypeSchema::Array(Box::new(HostTypeSchema::Resource(resource.clone()))), + HostParamPassing::Borrow, + )], + HostTypeSchema::Callable { + params: vec![HostTypeSchema::Int], + result: Box::new(HostTypeSchema::Optional(Box::new(HostTypeSchema::String))), + }, + ); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(resource, "embedded resource")); + builder.function(function.clone()); + let catalog = builder.build().expect("catalog"); + let schema = HostImportSchema::from_function(&catalog, &function); + + let mut program = Program::new(Vec::new(), vec![OpCode::Ret as u8]); + program.imports.push(HostImport { + name: "embedded::schema".to_string(), + arity: 1, + return_type: ValueType::Callable, + }); + let program = program + .with_host_import_schemas(vec![schema]) + .expect("schema alignment"); + let bytes = encode_program(&program).expect("schema payload should encode"); + let decoded = decode_program(&bytes).expect("embedded decoder should skip schema payload"); + assert_eq!(decoded.imports().len(), 1); +} + +#[test] +fn embedded_decoder_reads_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"); + 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)); +} + #[test] fn embedded_decoder_reads_nested_container_constants() { let source = Program::new( diff --git a/pd-vm-wasm/src/runtime.rs b/pd-vm-wasm/src/runtime.rs index 249f79fa..92439d96 100644 --- a/pd-vm-wasm/src/runtime.rs +++ b/pd-vm-wasm/src/runtime.rs @@ -11,9 +11,9 @@ use std::time::Instant; use serde::Deserialize; use vm::{ - CallOutcome, CallReturn, FunctionDecl, HostAsyncBridge, HostFunction, HostOpId, LocalInfo, - SourceFlavor, SourcePathError, Value, Vm, VmError, VmResult, VmStatus, VmYieldReason, - compile_source_with_flavor_and_options, format_value, render_vm_error, + CallOutcome, CallReturn, FunctionDecl, HostAsyncBridge, HostAsyncOpTerminal, HostFunction, + HostOpId, LocalInfo, SourceFlavor, SourcePathError, Value, Vm, VmError, VmResult, VmStatus, + VmYieldReason, compile_source_with_flavor_and_options, format_value, render_vm_error, }; use crate::analyzer::{LintDiagnostic, lint_source_with_flavor, lint_success_diagnostics}; @@ -294,6 +294,34 @@ impl HostAsyncBridge for BrowserAsyncBridge { Poll::Pending } } + + fn request_cancel_op( + &mut self, + op_id: HostOpId, + _reason: vm::operation::OperationCancelReason, + ) -> VmResult<()> { + let Ok(mut state) = self.state.lock() else { + return Err(VmError::HostError( + "browser async bridge state is unavailable".to_string(), + )); + }; + state.deadlines_ms.remove(&op_id); + Ok(()) + } + + fn poll_cancel_op(&mut self, _op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn cleanup_op(&mut self, op_id: HostOpId, _terminal: HostAsyncOpTerminal) -> VmResult<()> { + let Ok(mut state) = self.state.lock() else { + return Err(VmError::HostError( + "browser async bridge state is unavailable".to_string(), + )); + }; + state.deadlines_ms.remove(&op_id); + Ok(()) + } } struct PlaygroundRuntimeSleepHostFunction { @@ -1323,7 +1351,8 @@ fn register_functions( .any(|decl| decl.name == "runtime::sleep") .then(|| { let state = Arc::new(Mutex::new(BrowserAsyncState::default())); - vm.set_async_bridge(Box::new(BrowserAsyncBridge::new(Arc::clone(&state)))); + vm.set_async_bridge(Box::new(BrowserAsyncBridge::new(Arc::clone(&state)))) + .expect("browser async bridge should install"); state }); for decl in functions { diff --git a/src/builtins/metadata.rs b/src/builtins/metadata.rs index b7405f94..bc7bd590 100644 --- a/src/builtins/metadata.rs +++ b/src/builtins/metadata.rs @@ -10,6 +10,7 @@ pub enum CallableParamType { Array, Map, Number, + Resource, } impl CallableParamType { @@ -25,6 +26,7 @@ impl CallableParamType { Self::Array => "array", Self::Map => "map", Self::Number => "number", + Self::Resource => "resource", } } } diff --git a/src/builtins/runtime/context.rs b/src/builtins/runtime/context.rs new file mode 100644 index 00000000..78201c04 --- /dev/null +++ b/src/builtins/runtime/context.rs @@ -0,0 +1,3 @@ +//! Compatibility re-exports for the adapter runtime context. + +pub(crate) use crate::vm::runtime::{RuntimeContext, RuntimeContextConfig, STREAM_EMIT_NAME}; diff --git a/src/builtins/runtime/context_host.rs b/src/builtins/runtime/context_host.rs new file mode 100644 index 00000000..0cc3ba32 --- /dev/null +++ b/src/builtins/runtime/context_host.rs @@ -0,0 +1,12 @@ +use pd_host_function::pd_host_function; + +use super::AnyValue; +use crate::vm::{CallOutcome, Vm, VmResult}; + +/// Places one bounded event item on the active invocation stream and yields +/// control to the invocation poller. `stream::emit` still evaluates to `()` +/// inside RSS. +#[pd_host_function(name = "stream::emit")] +fn stream_emit_impl(vm: &mut Vm, value: AnyValue) -> VmResult { + vm.emit_stream_item(value) +} diff --git a/src/builtins/runtime/error.rs b/src/builtins/runtime/error.rs new file mode 100644 index 00000000..08c73e2b --- /dev/null +++ b/src/builtins/runtime/error.rs @@ -0,0 +1,3 @@ +//! Compatibility re-exports for the adapter runtime error API. + +pub use crate::vm::runtime::{RuntimeError, RuntimeErrorCode, RuntimeResult}; diff --git a/src/builtins/runtime/event.rs b/src/builtins/runtime/event.rs new file mode 100644 index 00000000..bb31d07b --- /dev/null +++ b/src/builtins/runtime/event.rs @@ -0,0 +1,7 @@ +//! Compatibility re-exports for invocation event validation. + +#[allow(unused_imports)] +pub use crate::vm::runtime::{ + DEFAULT_MAX_EVENT_DEPTH, DEFAULT_MAX_EVENT_PAYLOAD_BYTES, EventLimits, EventPayload, + estimate_value_size, +}; diff --git a/src/builtins/runtime/host.rs b/src/builtins/runtime/host.rs index 5cc99479..90bd0fe7 100644 --- a/src/builtins/runtime/host.rs +++ b/src/builtins/runtime/host.rs @@ -86,7 +86,7 @@ mod tests { vec![HostImport { name: name.to_string(), arity: 1, - return_type: crate::bytecode::ValueType::Bool, + return_type: crate::bytecode::ValueType::String, }], None, ) diff --git a/src/builtins/runtime/io/async_io.rs b/src/builtins/runtime/io/async_io.rs new file mode 100644 index 00000000..56d24649 --- /dev/null +++ b/src/builtins/runtime/io/async_io.rs @@ -0,0 +1,1010 @@ +//! Feature-selected async IO host implementation. +//! +//! This is the `async`-feature counterpart of the worker-thread +//! [`blocking`](super::blocking) implementation. Live handles are typed +//! [`IoResource`]s owned by the VM's execution scope (exactly like the +//! blocking path) and in-flight IO work runs through tokio; the guest-facing +//! builtins are async host functions that capture owned host context and +//! submit a future through the generic async host bridge. +//! +//! The guest-visible handle id is the raw resource token, so handles opened +//! on one path can be closed/read on the other. + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::task::{Context, Poll, Waker}; + +use pd_host_function::pd_host_function; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::sync::Mutex; + +use super::{IoPolicy, io_policy}; +use crate::vm::resource::close::{CloseProgress, HostResource}; +use crate::vm::resource::error::{ResourceError, ResourceErrorCode, ResourceResult}; +use crate::vm::resource::{ResourceCloseReason, ResourceHandle}; +use crate::vm::{CaptureAsyncHostContext, HostFutureOutput, Value, Vm, VmError, VmResult}; + +/// A file / child-process backed IO handle. +#[derive(Debug)] +pub(crate) enum IoHandle { + File(BufReader), + PopenRead { + child: Child, + stdout: BufReader, + }, + PopenWrite { + child: Child, + stdin: ChildStdin, + }, +} + +type CloseFuture = Pin> + Send + 'static>>; + +/// The typed resource stored in the execution scope for one async IO handle. +/// +/// Mirrors the blocking path: the handle lives behind an `Arc>` +/// so the async builtin can take/restore it while the resource stays in the +/// scope table. Closing is exact-once. +struct IoResource { + handle: Arc>>, + closed: Arc, + process_id: Arc, + active_operations: Arc, + close_waker: Arc>>, + close_scheduled: Arc, + close_future: Option, + owner: bool, + owner_alive: Arc, +} + +impl IoResource { + fn new(handle: IoHandle) -> Self { + let process_id = match &handle { + IoHandle::PopenRead { child, .. } | IoHandle::PopenWrite { child, .. } => { + child.id().unwrap_or(0) + } + IoHandle::File(_) => 0, + }; + Self { + handle: Arc::new(Mutex::new(Some(handle))), + closed: Arc::new(AtomicBool::new(false)), + process_id: Arc::new(AtomicU32::new(process_id)), + active_operations: Arc::new(AtomicUsize::new(0)), + close_waker: Arc::new(StdMutex::new(None)), + close_scheduled: Arc::new(AtomicBool::new(false)), + close_future: None, + owner: true, + owner_alive: Arc::new(AtomicBool::new(true)), + } + } + + fn new_shared(cells: &IoResource) -> Self { + Self { + handle: Arc::clone(&cells.handle), + closed: Arc::clone(&cells.closed), + process_id: Arc::clone(&cells.process_id), + active_operations: Arc::clone(&cells.active_operations), + close_waker: Arc::clone(&cells.close_waker), + close_scheduled: Arc::clone(&cells.close_scheduled), + close_future: None, + owner: false, + owner_alive: Arc::clone(&cells.owner_alive), + } + } + + fn begin_operation(&self, operation: &'static str) -> VmResult { + if self.closed.load(Ordering::Acquire) { + return Err(VmError::HostError(format!("{operation} handle is closed"))); + } + self.active_operations.fetch_add(1, Ordering::AcqRel); + if self.closed.load(Ordering::Acquire) { + self.active_operations.fetch_sub(1, Ordering::AcqRel); + wake_close_waker(&self.close_waker); + return Err(VmError::HostError(format!("{operation} handle is closed"))); + } + Ok(IoOperationLease { + active_operations: Arc::clone(&self.active_operations), + close_waker: Arc::clone(&self.close_waker), + handle: Arc::clone(&self.handle), + closed: Arc::clone(&self.closed), + owner_alive: Arc::clone(&self.owner_alive), + close_scheduled: Arc::clone(&self.close_scheduled), + process_id: Arc::clone(&self.process_id), + completed: false, + }) + } + + fn schedule_close(&mut self, reason: ResourceCloseReason) { + if self.close_future.is_some() { + return; + } + self.close_scheduled.store(true, Ordering::Release); + let handle = Arc::clone(&self.handle); + let process_id = Arc::clone(&self.process_id); + self.close_future = Some(Box::pin(async move { + let handle = handle.lock().await.take(); + let result = match handle { + Some(handle) => close_io_handle(handle, reason).await, + None => Ok(()), + }; + if result.is_ok() { + process_id.store(0, Ordering::Release); + } + result + })); + } + + fn wait_for_operations(&self, cx: &Context<'_>) -> bool { + if self.active_operations.load(Ordering::Acquire) == 0 { + return false; + } + let mut wake = None; + let pending = { + let mut slot = self + .close_waker + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if self.active_operations.load(Ordering::Acquire) == 0 { + false + } else { + *slot = Some(cx.waker().clone()); + if self.active_operations.load(Ordering::Acquire) == 0 { + wake = slot.take(); + false + } else { + true + } + } + }; + if let Some(waker) = wake { + waker.wake(); + } + pending + } + + fn take_handle(&self) -> impl Future> + Send + 'static { + let handle = Arc::clone(&self.handle); + async move { + handle + .lock() + .await + .take() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string())) + } + } +} + +struct IoOperationLease { + active_operations: Arc, + close_waker: Arc>>, + handle: Arc>>, + closed: Arc, + owner_alive: Arc, + close_scheduled: Arc, + process_id: Arc, + completed: bool, +} + +impl Drop for IoOperationLease { + fn drop(&mut self) { + if !self.completed { + self.closed.store(true, Ordering::Release); + terminate_process_id( + self.process_id.load(Ordering::Acquire), + ResourceCloseReason::ResourceClosed, + ); + if !self.close_scheduled.load(Ordering::Acquire) { + self.process_id.store(0, Ordering::Release); + } + } + let previous = self.active_operations.fetch_sub(1, Ordering::AcqRel); + debug_assert!(previous > 0, "IO operation lease count underflowed"); + if previous != 1 { + return; + } + if self.closed.load(Ordering::Acquire) + && (!self.owner_alive.load(Ordering::Acquire) + || !self.close_scheduled.load(Ordering::Acquire)) + && let Ok(mut guard) = self.handle.try_lock() + { + drop(guard.take()); + } + wake_close_waker(&self.close_waker); + } +} + +impl IoOperationLease { + fn complete(&mut self) { + self.completed = true; + } +} + +fn wake_close_waker(close_waker: &StdMutex>) { + if let Some(waker) = close_waker + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + waker.wake(); + } +} + +impl Drop for IoHandle { + fn drop(&mut self) { + match self { + Self::PopenRead { child, .. } | Self::PopenWrite { child, .. } => { + reap_child_now(child, ResourceCloseReason::VmDrop); + } + Self::File(_) => {} + } + } +} + +fn reap_child_now(child: &mut Child, reason: ResourceCloseReason) { + let Some(pid) = child.id() else { + return; + }; + terminate_process_id(pid, reason); + let _ = child.start_kill(); + for _ in 0..200 { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(1)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(_) => return, + } + } +} + +impl Drop for IoResource { + fn drop(&mut self) { + if !self.owner { + return; + } + self.owner_alive.store(false, Ordering::Release); + self.closed.store(true, Ordering::Release); + let pid = self.process_id.load(Ordering::Acquire); + terminate_process_id(pid, ResourceCloseReason::VmDrop); + if let Ok(mut guard) = self.handle.try_lock() { + drop(guard.take()); + } + self.close_waker + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + } +} + +impl HostResource for IoResource { + fn begin_close(&mut self, reason: ResourceCloseReason) -> ResourceResult { + self.closed.store(true, Ordering::Release); + let pid = self.process_id.load(Ordering::Acquire); + if pid != 0 { + terminate_process_id(pid, reason); + } + self.schedule_close(reason); + if self.active_operations.load(Ordering::Acquire) != 0 { + return Ok(CloseProgress::Pending); + } + match self.handle.try_lock() { + Ok(guard) if guard.is_none() => { + self.close_future = None; + self.process_id.store(0, Ordering::Release); + Ok(CloseProgress::Ready) + } + Ok(_) | Err(_) => Ok(CloseProgress::Pending), + } + } + + fn poll_close(&mut self, cx: &mut Context<'_>) -> Poll> { + if self.wait_for_operations(cx) { + return Poll::Pending; + } + if tokio::runtime::Handle::try_current().is_err() { + // A close future must not be discarded while an operation is still + // active. Once operations are quiescent, there is no reactor in + // which to flush/finish the future, so report a concrete cleanup + // error and let the resource table decide how to retire the slot. + return Poll::Ready(Err(ResourceError::new( + ResourceErrorCode::ResourceCleanupFailed, + "io::resource", + "async IO close requires a Tokio runtime", + ))); + } + let Some(close_future) = self.close_future.as_mut() else { + return Poll::Ready(Ok(())); + }; + match close_future.as_mut().poll(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(())) => { + self.close_future = None; + Poll::Ready(Ok(())) + } + Poll::Ready(Err(error)) => { + self.close_future = None; + Poll::Ready(Err(ResourceError::new( + ResourceErrorCode::ResourceCleanupFailed, + "io::resource", + error.to_string(), + ))) + } + } + } +} + +async fn close_io_handle(mut handle: IoHandle, reason: ResourceCloseReason) -> VmResult<()> { + match &mut handle { + IoHandle::File(file) => { + file.get_mut() + .flush() + .await + .map_err(|error| VmError::HostError(format!("io_close flush failed: {error}")))?; + } + IoHandle::PopenRead { child, .. } => { + terminate_process_id(child.id().unwrap_or(0), reason); + child.kill().await.map_err(|error| { + VmError::HostError(format!("io_close popen wait failed: {error}")) + })?; + } + IoHandle::PopenWrite { child, stdin } => { + let _ = stdin.shutdown().await; + terminate_process_id(child.id().unwrap_or(0), reason); + child.kill().await.map_err(|error| { + VmError::HostError(format!("io_close popen wait failed: {error}")) + })?; + } + } + Ok(()) +} + +fn terminate_process_id(pid: u32, reason: ResourceCloseReason) { + if pid == 0 { + return; + } + let _ = reason; + #[cfg(unix)] + { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return; + }; + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } + #[cfg(windows)] + { + let _ = std::process::Command::new("taskkill") + .args(["/T", "/F", "/PID", &pid.to_string()]) + .status(); + } + #[cfg(not(any(unix, windows)))] + let _ = pid; +} + +/// The per-call captured policy context. +#[derive(Clone)] +pub(crate) struct IoPolicyContext { + policy: Option, +} + +impl CaptureAsyncHostContext for IoPolicyContext { + fn capture(vm: &mut Vm) -> VmResult { + Ok(Self { + policy: io_policy(vm), + }) + } +} + +/// The per-call captured handle context: shared resource cells plus the +/// policy byte limits, captured before the future is submitted. +pub(crate) struct IoHandleContext { + handle: ResourceHandle, + resource: IoResource, + max_read_bytes: Option, + max_write_bytes: Option, +} + +impl CaptureAsyncHostContext for IoHandleContext { + fn capture(_vm: &mut Vm) -> VmResult { + Err(VmError::HostError( + "io handle context requires call arguments".to_string(), + )) + } + + fn capture_with_args(vm: &mut Vm, args: &[Value]) -> VmResult { + let handle_id = match args.first() { + Some(Value::Int(value)) => *value, + Some(_) => return Err(VmError::TypeMismatch("int")), + None => return Err(VmError::HostError("missing io handle argument".to_string())), + }; + let handle = io_parse_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + Ok(Self { + handle, + resource, + max_read_bytes: io_policy(vm).map(|policy| policy.max_read_bytes), + max_write_bytes: io_policy(vm).map(|policy| policy.max_write_bytes), + }) + } +} + +/// Opens a file handle for runtime I/O. +#[pd_host_function(name = "io::open")] +pub(crate) async fn builtin_io_open( + #[pd_host_context] context: IoPolicyContext, + path: String, + mode: String, +) -> VmResult> { + let writes = match mode.as_str() { + "r" => false, + "w" | "a" | "r+" | "w+" | "a+" => true, + other => { + return Err(VmError::HostError(format!( + "io_open unsupported mode '{other}'" + ))); + } + }; + let path = authorize_io_path(context.policy.as_ref(), &path, writes).await?; + let mut options = OpenOptions::new(); + match mode.as_str() { + "r" => { + options.read(true); + } + "w" => { + options.write(true).create(true).truncate(true); + } + "a" => { + options.write(true).create(true).append(true); + } + "r+" => { + options.read(true).write(true); + } + "w+" => { + options.read(true).write(true).create(true).truncate(true); + } + "a+" => { + options.read(true).write(true).create(true).append(true); + } + _ => unreachable!(), + } + let file = options + .open(path) + .await + .map_err(|error| VmError::HostError(format!("io_open failed: {error}")))?; + let handle = IoHandle::File(BufReader::new(file)); + Ok(HostFutureOutput::complete(move |vm| { + let token = vm + .execution_scope() + .push_resource(IoResource::new(handle)) + .map_err(|error| VmError::HostError(format!("io resource insert failed: {error}")))?; + Ok(token.into_handle().raw() as i64) + })) +} + +/// Starts a child process and returns a process-backed handle. +#[pd_host_function(name = "io::popen")] +pub(crate) async fn builtin_io_popen( + #[pd_host_context] context: IoPolicyContext, + command: String, + mode: String, +) -> VmResult> { + if mode != "r" && mode != "w" { + return Err(VmError::HostError(format!( + "io_popen unsupported mode '{mode}'" + ))); + } + if !context + .policy + .as_ref() + .is_none_or(|policy| policy.allow_process) + { + return Err(VmError::HostError( + "io_popen requires the command capability".to_string(), + )); + } + let handle = spawn_shell_command(&command, &mode)?; + Ok(HostFutureOutput::complete(move |vm| { + let token = vm + .execution_scope() + .push_resource(IoResource::new(handle)) + .map_err(|error| VmError::HostError(format!("io resource insert failed: {error}")))?; + Ok(token.into_handle().raw() as i64) + })) +} + +/// Reads all remaining text from an I/O handle. +#[pd_host_function(name = "io::read_all")] +pub(crate) async fn builtin_io_read_all( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let mut lease = context.resource.begin_operation("io_read_all")?; + let mut guard = context.resource.handle.lock().await; + if context.resource.closed.load(Ordering::Acquire) { + return Err(VmError::HostError( + "io_read_all handle is closed".to_string(), + )); + } + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + let mut out = String::new(); + match handle { + IoHandle::File(file) => file.read_to_string(&mut out).await, + IoHandle::PopenRead { stdout, .. } => stdout.read_to_string(&mut out).await, + IoHandle::PopenWrite { .. } => { + return Err(VmError::HostError( + "io_read_all cannot read from a write handle".to_string(), + )); + } + } + .map_err(|error| VmError::HostError(format!("io_read_all failed: {error}")))?; + if context.resource.closed.load(Ordering::Acquire) { + return Err(VmError::HostError( + "io_read_all handle is closed".to_string(), + )); + } + if context + .max_read_bytes + .is_some_and(|limit| out.len() > limit) + { + return Err(VmError::HostError( + "io_read_all exceeded read limit".to_string(), + )); + } + lease.complete(); + Ok(HostFutureOutput::returning(out)) +} + +/// Reads a single line of text from an I/O handle. +#[pd_host_function(name = "io::read_line")] +pub(crate) async fn builtin_io_read_line( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let mut lease = context.resource.begin_operation("io_read_line")?; + let mut guard = context.resource.handle.lock().await; + if context.resource.closed.load(Ordering::Acquire) { + return Err(VmError::HostError( + "io_read_line handle is closed".to_string(), + )); + } + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + let mut line = String::new(); + match handle { + IoHandle::File(file) => file.read_line(&mut line).await, + IoHandle::PopenRead { stdout, .. } => stdout.read_line(&mut line).await, + IoHandle::PopenWrite { .. } => { + return Err(VmError::HostError( + "io_read_line cannot read from a write handle".to_string(), + )); + } + } + .map_err(|error| VmError::HostError(format!("io_read_line failed: {error}")))?; + if context.resource.closed.load(Ordering::Acquire) { + return Err(VmError::HostError( + "io_read_line handle is closed".to_string(), + )); + } + if context + .max_read_bytes + .is_some_and(|limit| line.len() > limit) + { + return Err(VmError::HostError( + "io_read_line exceeded read limit".to_string(), + )); + } + lease.complete(); + Ok(HostFutureOutput::returning(line)) +} + +/// Writes text to an I/O handle. +#[pd_host_function(name = "io::write")] +pub(crate) async fn builtin_io_write( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, + text: String, +) -> VmResult> { + if context + .max_write_bytes + .is_some_and(|limit| text.len() > limit) + { + return Err(VmError::HostError( + "io_write exceeded write limit".to_string(), + )); + } + let mut lease = context.resource.begin_operation("io_write")?; + let mut guard = context.resource.handle.lock().await; + if context.resource.closed.load(Ordering::Acquire) { + return Err(VmError::HostError("io_write handle is closed".to_string())); + } + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + let written = match handle { + IoHandle::File(file) => file.get_mut().write(text.as_bytes()).await, + IoHandle::PopenWrite { stdin, .. } => stdin.write(text.as_bytes()).await, + IoHandle::PopenRead { .. } => { + return Err(VmError::HostError( + "io_write cannot write to a read handle".to_string(), + )); + } + } + .map_err(|error| VmError::HostError(format!("io_write failed: {error}")))?; + if context.resource.closed.load(Ordering::Acquire) { + return Err(VmError::HostError("io_write handle is closed".to_string())); + } + lease.complete(); + Ok(HostFutureOutput::returning(written as i64)) +} + +/// Flushes buffered output for an I/O handle. +#[pd_host_function(name = "io::flush")] +pub(crate) async fn builtin_io_flush( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let mut lease = context.resource.begin_operation("io_flush")?; + let mut guard = context.resource.handle.lock().await; + if context.resource.closed.load(Ordering::Acquire) { + return Err(VmError::HostError("io_flush handle is closed".to_string())); + } + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + match handle { + IoHandle::File(file) => file.get_mut().flush().await, + IoHandle::PopenWrite { stdin, .. } => stdin.flush().await, + IoHandle::PopenRead { .. } => Ok(()), + } + .map_err(|error| VmError::HostError(format!("io_flush failed: {error}")))?; + if context.resource.closed.load(Ordering::Acquire) { + return Err(VmError::HostError("io_flush handle is closed".to_string())); + } + lease.complete(); + Ok(HostFutureOutput::returning(true)) +} + +/// Closes an I/O handle. +#[pd_host_function(name = "io::close")] +pub(crate) async fn builtin_io_close( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let mut lease = context.resource.begin_operation("io_close")?; + let resource = IoResource::new_shared(&context.resource); + let handle = context.handle; + let owned_handle = resource.take_handle().await?; + let close_result = close_io_handle(owned_handle, ResourceCloseReason::Requested).await; + if close_result.is_ok() { + context.resource.process_id.store(0, Ordering::Release); + } + lease.complete(); + Ok(HostFutureOutput::complete(move |vm| { + let progress = vm + .execution_scope() + .close_resource::(handle, ResourceCloseReason::Requested) + .map_err(|error| { + VmError::HostError(format!("io_close scope retirement failed: {error}")) + })?; + if progress != CloseProgress::Ready { + return Err(VmError::HostError( + "io_close scope retirement is still pending".to_string(), + )); + } + close_result?; + Ok(true) + })) +} + +/// Returns whether a file system path exists. +#[pd_host_function(name = "io::exists")] +pub(crate) async fn builtin_io_exists( + #[pd_host_context] context: IoPolicyContext, + path: String, +) -> VmResult> { + let path = authorize_io_path(context.policy.as_ref(), &path, false).await?; + let exists = tokio::fs::try_exists(path) + .await + .map_err(|error| VmError::HostError(format!("io_exists failed: {error}")))?; + Ok(HostFutureOutput::returning(exists)) +} + +async fn authorize_io_path( + policy: Option<&IoPolicy>, + path: &str, + writes: bool, +) -> VmResult { + let requested = PathBuf::from(path); + let Some(policy) = policy else { + return Ok(requested); + }; + if writes && !policy.allow_write { + return Err(VmError::HostError( + "io path write requires the write capability".to_string(), + )); + } + let absolute = if requested.is_absolute() { + requested + } else { + std::env::current_dir() + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))? + .join(requested) + }; + let canonical = canonicalize_io_target(&absolute).await?; + for root in &policy.allowed_roots { + let root = tokio::fs::canonicalize(Path::new(root)) + .await + .map_err(|error| { + VmError::HostError(format!( + "io allowed root '{root}' cannot be resolved: {error}" + )) + })?; + if canonical.starts_with(root) { + return Ok(canonical); + } + } + Err(VmError::HostError(format!( + "io path '{}' is outside the allowed roots", + canonical.display() + ))) +} + +async fn canonicalize_io_target(path: &Path) -> VmResult { + if tokio::fs::try_exists(path) + .await + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))? + { + return tokio::fs::canonicalize(path) + .await + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}"))); + } + // The target does not exist yet (e.g. a create-mode open): canonicalize + // the parent and append the final component. + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let canonical_parent = tokio::fs::canonicalize(parent) + .await + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))?; + let name = path + .file_name() + .ok_or_else(|| VmError::HostError("io path has no file name".to_string()))?; + Ok(canonical_parent.join(name)) +} + +/// Looks up the shared cells of a live IO handle resource in the execution +/// scope, cloning them so the async builtin can take/restore the handle +/// while the resource stays in the scope table. +fn io_resource_for_handle(vm: &mut Vm, handle: ResourceHandle) -> VmResult { + let token = vm + .execution_scope() + .resources() + .typed::(handle) + .map_err(|error| { + VmError::HostError(format!( + "io handle {:?} is not a live IO handle: {error}", + handle.raw() + )) + })?; + let resource = vm + .execution_scope() + .resources() + .get::(&token) + .map_err(|error| { + VmError::HostError(format!( + "io handle {:?} borrow failed: {error}", + handle.raw() + )) + })?; + Ok(IoResource::new_shared(&resource)) +} + +fn io_parse_handle(handle_id: i64) -> VmResult { + if handle_id <= 0 { + return Err(VmError::HostError(format!( + "invalid io handle id {handle_id}; expected positive handle id" + ))); + } + ResourceHandle::from_raw(handle_id as u64) + .map_err(|error| VmError::HostError(format!("invalid io handle id {handle_id}: {error}"))) +} + +fn spawn_shell_command(shell_command: &str, mode: &str) -> VmResult { + let mut process = if cfg!(windows) { + let mut command = Command::new("cmd"); + command.arg("/C").arg(shell_command); + command + } else { + let mut command = Command::new("sh"); + command.arg("-c").arg(shell_command); + command + }; + + #[cfg(unix)] + process.process_group(0); + process.kill_on_drop(true); + + match mode { + "r" => { + process.stdout(Stdio::piped()).stdin(Stdio::null()); + } + "w" => { + process.stdin(Stdio::piped()).stdout(Stdio::null()); + } + _ => {} + } + + let mut child = process + .spawn() + .map_err(|error| VmError::HostError(format!("io_popen spawn failed: {error}")))?; + + if mode == "r" { + let Some(stdout) = child.stdout.take() else { + terminate_process_id(child.id().unwrap_or(0), ResourceCloseReason::VmDrop); + let _ = child.start_kill(); + return Err(VmError::HostError( + "io_popen('r') did not provide stdout pipe".to_string(), + )); + }; + Ok(IoHandle::PopenRead { + child, + stdout: BufReader::new(stdout), + }) + } else { + let Some(stdin) = child.stdin.take() else { + terminate_process_id(child.id().unwrap_or(0), ResourceCloseReason::VmDrop); + let _ = child.start_kill(); + return Err(VmError::HostError( + "io_popen('w') did not provide stdin pipe".to_string(), + )); + }; + Ok(IoHandle::PopenWrite { child, stdin }) + } +} + +#[cfg(test)] +mod tests { + use std::task::{Context, Poll, Waker}; + + use super::*; + use crate::return_one; + + fn file_resource() -> IoResource { + let file = std::fs::File::open("Cargo.toml").expect("test fixture should exist"); + IoResource::new(IoHandle::File(BufReader::new(File::from_std(file)))) + } + + async fn assert_close_waits_for_busy_handle_lock() { + let mut resource = file_resource(); + let handle = Arc::clone(&resource.handle); + let guard = handle.lock().await; + let lease = resource + .begin_operation("test") + .expect("test operation should start"); + let reason = ResourceCloseReason::Requested; + + assert_eq!( + resource.begin_close(reason).expect("close should start"), + CloseProgress::Pending, + "close must stay pending while an async operation owns the handle lock" + ); + + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!(resource.poll_close(&mut cx), Poll::Pending)); + + drop(guard); + drop(lease); + assert!(matches!(resource.poll_close(&mut cx), Poll::Ready(Ok(())))); + } + + #[tokio::test] + async fn async_io_close_while_read_lock_is_busy_stays_pending() { + assert_close_waits_for_busy_handle_lock().await; + } + + #[tokio::test] + async fn async_io_close_while_write_lock_is_busy_stays_pending() { + assert_close_waits_for_busy_handle_lock().await; + } + + #[test] + fn async_io_close_without_runtime_waits_for_active_operations_then_reports_error() { + let mut resource = file_resource(); + let lease = resource + .begin_operation("test") + .expect("test operation should start"); + assert_eq!( + resource + .begin_close(ResourceCloseReason::Requested) + .expect("close should start"), + CloseProgress::Pending + ); + + let mut cx = Context::from_waker(Waker::noop()); + assert!( + matches!(resource.poll_close(&mut cx), Poll::Pending), + "a no-runtime close must not release an active IO handle" + ); + + drop(lease); + match resource.poll_close(&mut cx) { + Poll::Ready(Err(error)) => { + assert_eq!(error.code(), ResourceErrorCode::ResourceCleanupFailed); + } + other => panic!("no-runtime close should surface a concrete error, got {other:?}"), + } + } + + #[cfg(unix)] + #[tokio::test] + async fn async_io_child_close_polls_until_child_is_reaped() { + let mut resource = IoResource::new(spawn_shell_command("sleep 30", "r").expect("spawn")); + let pid = resource.process_id.load(Ordering::Acquire); + assert_ne!(pid, 0); + + assert_eq!( + resource + .begin_close(ResourceCloseReason::Requested) + .expect("close should start"), + CloseProgress::Pending + ); + + std::future::poll_fn(|cx| resource.poll_close(cx)) + .await + .expect("child close should succeed"); + assert!( + !std::path::Path::new(&format!("/proc/{pid}")).exists(), + "poll_close must wait for the child to be reaped" + ); + } + + #[tokio::test] + async fn async_io_close_propagates_scope_retirement_errors() { + let compiled = crate::compile_source("0;").expect("test program should compile"); + let mut vm = Vm::new(compiled.program); + let resource = IoResource::new(spawn_shell_command("sleep 30", "r").expect("spawn")); + let shared = IoResource::new_shared(&resource); + let token = vm + .execution_scope() + .push_resource(resource) + .expect("resource should insert"); + let context = IoHandleContext { + handle: token.handle(), + resource: shared, + max_read_bytes: None, + max_write_bytes: None, + }; + let mut close_future = + Box::pin(builtin_io_close_impl(context, token.handle().raw() as i64)); + let mut cx = Context::from_waker(Waker::noop()); + + assert!(matches!(close_future.as_mut().poll(&mut cx), Poll::Pending)); + assert_eq!( + vm.execution_scope() + .close_resource::(token.handle(), ResourceCloseReason::Requested) + .expect("concurrent close should start"), + CloseProgress::Pending + ); + + let output = close_future + .await + .expect("close future should complete") + .map(return_one); + let error = output + .finish(&mut vm) + .expect_err("scope retirement failure must reach the guest"); + assert!( + error.to_string().contains("already closed") + || error.to_string().contains("closing") + || error.to_string().contains("resource"), + "unexpected scope retirement error: {error}" + ); + } +} diff --git a/src/builtins/runtime/io/blocking.rs b/src/builtins/runtime/io/blocking.rs index 033e79ef..633418e6 100644 --- a/src/builtins/runtime/io/blocking.rs +++ b/src/builtins/runtime/io/blocking.rs @@ -1,9 +1,9 @@ -use std::collections::HashMap; use std::fs::OpenOptions; use std::io::{Read, Write}; +use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll, Waker}; use std::thread::JoinHandle; @@ -14,31 +14,12 @@ use super::HostCallResult; use crate::vm::operation::driver::HostOperation; use crate::vm::operation::error::{OperationError, OperationErrorCode, OperationResult}; use crate::vm::operation::reason::OperationCancelReason; -use crate::vm::operation::{OperationId, OperationSpec}; +use crate::vm::operation::{OperationId, OperationOutcome, OperationSpec}; use crate::vm::resource::close::{CloseProgress, HostResource}; use crate::vm::resource::error::{ResourceError, ResourceErrorCode, ResourceResult}; use crate::vm::resource::{ResourceCloseReason, ResourceHandle}; use crate::vm::{CallReturn, HostOpId, Value, Vm, VmError, VmResult}; -/// Adapter-declared per-VM IO host state. -/// -/// Live IO handles are typed [`IoResource`]s owned by the VM's execution -/// scope; in-flight IO work is driven by concrete [`HostOperation`] drivers -/// registered in the same scope. This state itself lives in the execution -/// scope's typed arena (accessed lazily through -/// `ExecutionScope::scope_state_or_insert_with`), so it follows the scope -/// lifecycle: it is destroyed on reset/drop and recreated fresh on next use. -/// The only state kept here is the per-op completion mailbox that carries the -/// guest-visible result value from the worker thread back to -/// [`poll_builtin_io_op`]. Polling and cancellation of the operations -/// themselves go directly through the scope's operation registry — this map -/// is a value mailbox, not a poller table. -#[derive(Default)] -pub(crate) struct IoState { - /// Packed [`OperationId::raw`] -> completion mailbox for pending IO ops. - pending_results: HashMap>, -} - /// A file / child-process backed IO handle. pub(super) enum IoHandle { File(std::fs::File), @@ -46,54 +27,253 @@ pub(super) enum IoHandle { PopenWrite { child: Child }, } -/// The typed resource stored in the execution scope for one IO handle. +/// Shared lifecycle state for one typed IO resource. /// -/// The handle lives behind an `Arc>>` so a worker thread -/// performing read/write/flush/close can transiently take the handle while -/// the resource itself stays in the scope table. Closing is exact-once: the -/// first close (via `io::close` worker or the generic scope close) takes the -/// handle and releases the OS resource. -struct IoResource { - handle: Arc>>, - closed: Arc, +/// The handle cell is also the admission lock for workers: a worker increments +/// `active_workers` while holding the cell lock before taking the handle, and a +/// close marks the resource closed before inspecting that same cell. This +/// makes a close racing with a worker either reject the worker or observe it as +/// active; it can never mistake an owned handle for an idle resource. +struct IoResourceState { + handle: Mutex>, + closed: AtomicBool, + active_workers: AtomicUsize, + close_waker: Mutex>, + close_error: Mutex>, } -impl IoResource { +impl IoResourceState { fn new(handle: IoHandle) -> Self { Self { - handle: Arc::new(Mutex::new(Some(handle))), - closed: Arc::new(AtomicBool::new(false)), + handle: Mutex::new(Some(handle)), + closed: AtomicBool::new(false), + active_workers: AtomicUsize::new(0), + close_waker: Mutex::new(None), + close_error: Mutex::new(None), } } - /// Takes the inner handle for a worker thread (exact-once per close). - fn take_handle(&self) -> Option { - self.handle + /// Takes the handle for one worker and records its ownership before + /// releasing the admission lock. + fn take_handle(self: &Arc) -> Option { + let mut slot = self + .handle .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if self.closed.load(Ordering::Acquire) { + return None; + } + let handle = slot.take()?; + self.active_workers.fetch_add(1, Ordering::AcqRel); + Some(IoHandleLease { + state: Arc::clone(self), + handle: Some(handle), + active: true, + }) } - /// Restores a handle a worker took, unless the resource is already - /// closing — in which case the handle is dropped to release the OS - /// resource rather than re-inserted into a closing resource. - fn restore_handle(&self, handle: IoHandle) { - if self.closed.load(Ordering::SeqCst) { - let _ = close_io_handle(handle); + fn mark_closed(&self) { + let _guard = self + .handle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + self.closed.store(true, Ordering::Release); + } + + fn register_close_waker(&self, waker: &Waker) { + let mut guard = self + .close_waker + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if self.active_workers.load(Ordering::Acquire) == 0 { return; } - *self - .handle + *guard = Some(waker.clone()); + if self.active_workers.load(Ordering::Acquire) == 0 + && let Some(waker) = guard.take() + { + waker.wake(); + } + } + + fn release_worker(&self) { + let previous = self.active_workers.fetch_sub(1, Ordering::AcqRel); + debug_assert!(previous > 0, "IO worker release without an active worker"); + if previous == 1 + && let Some(waker) = self + .close_waker + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + waker.wake(); + } + } + + fn record_close_error(&self, error: &VmError) { + let mut guard = self + .close_error .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(handle); + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.is_none() { + *guard = Some(error.to_string()); + } + } + + fn cleanup_error(&self) -> Option { + self.close_error + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .map(|message| { + ResourceError::new( + ResourceErrorCode::ResourceCleanupFailed, + "io::resource", + message.clone(), + ) + }) + } +} + +impl Drop for IoResourceState { + fn drop(&mut self) { + let handle = self + .handle + .get_mut() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(handle) = handle { + let _ = close_io_handle(handle); + } + } +} + +/// A worker-owned handle lease. Normal completion explicitly restores the +/// handle to an open resource, while close/cancellation/unwind paths close it +/// instead. In either case the active-worker count is decremented and a +/// pending resource close is woken. +struct IoHandleLease { + state: Arc, + handle: Option, + active: bool, +} + +impl Deref for IoHandleLease { + type Target = IoHandle; + + fn deref(&self) -> &Self::Target { + self.handle.as_ref().expect("active IO lease has a handle") + } +} + +impl DerefMut for IoHandleLease { + fn deref_mut(&mut self) -> &mut Self::Target { + self.handle.as_mut().expect("active IO lease has a handle") + } +} + +impl IoHandleLease { + fn restore(mut self) -> VmResult<()> { + self.release_inner(false) + } + + fn close(mut self) -> VmResult<()> { + self.release_inner(true) + } + + fn release_inner(&mut self, force_close: bool) -> VmResult<()> { + if !self.active { + return Ok(()); + } + let Some(handle) = self.handle.take() else { + self.active = false; + self.state.release_worker(); + return Ok(()); + }; + + let mut handle = Some(handle); + let should_close = if force_close { + true + } else { + let mut slot = self + .state + .handle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if self.state.closed.load(Ordering::Acquire) { + true + } else { + *slot = handle.take(); + false + } + }; + + let result = if should_close { + close_io_handle(handle.expect("IO lease close owns its handle")) + } else { + Ok(()) + }; + if let Err(error) = &result { + self.state.record_close_error(error); + } + self.active = false; + self.state.release_worker(); + result + } +} + +impl Drop for IoHandleLease { + fn drop(&mut self) { + if self.active { + // A normal worker calls `restore`/`close` explicitly. Reaching this + // guard means an unwind or failed handoff, so never return a live + // process handle to the resource table implicitly. + let _ = self.release_inner(true); + } + } +} + +/// The typed resource stored in the execution scope for one IO handle. +struct IoResource { + state: Arc, +} + +impl IoResource { + fn new(handle: IoHandle) -> Self { + Self { + state: Arc::new(IoResourceState::new(handle)), + } + } + + /// Takes the inner handle for a worker thread and records its lease. + fn take_handle(&self) -> Option { + self.state.take_handle() } } impl HostResource for IoResource { fn begin_close(&mut self, _reason: ResourceCloseReason) -> ResourceResult { - self.closed.store(true, Ordering::SeqCst); - if let Some(handle) = self.take_handle() { + // Marking closed while holding the same admission lock used by worker + // leases makes the close boundary linearizable: a worker either + // restores before close begins, or observes closed and cleans up. + let mut slot = self + .state + .handle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + self.state.closed.store(true, Ordering::Release); + if self.state.active_workers.load(Ordering::Acquire) != 0 { + return Ok(CloseProgress::Pending); + } + let handle = slot.take(); + drop(slot); + + if let Some(error) = self.state.cleanup_error() { + return Err(error); + } + if let Some(handle) = handle { close_io_handle(handle).map_err(|error| { + self.state.record_close_error(&error); ResourceError::new( ResourceErrorCode::ResourceCleanupFailed, "io::resource", @@ -103,10 +283,40 @@ impl HostResource for IoResource { } Ok(CloseProgress::Ready) } + + fn poll_close(&mut self, cx: &mut Context<'_>) -> Poll> { + if self.state.active_workers.load(Ordering::Acquire) != 0 { + self.state.register_close_waker(cx.waker()); + if self.state.active_workers.load(Ordering::Acquire) != 0 { + return Poll::Pending; + } + } + + if let Some(error) = self.state.cleanup_error() { + return Poll::Ready(Err(error)); + } + let handle = self + .state + .handle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(handle) = handle + && let Err(error) = close_io_handle(handle) + { + self.state.record_close_error(&error); + return Poll::Ready(Err(ResourceError::new( + ResourceErrorCode::ResourceCleanupFailed, + "io::resource", + error.to_string(), + ))); + } + Poll::Ready(Ok(())) + } } /// Shared state between one IO worker thread, its [`IoOpDriver`] operation, -/// and [`poll_builtin_io_op`] on the VM thread. +/// and the adapter-owned completion hook on the VM thread. /// /// The worker writes the terminal [`signal`](IoOpShared::signal), the /// guest-visible [`value`](IoOpShared::value), and any opened handle or @@ -259,15 +469,34 @@ impl IoOpShared { self.publish(Err(message)); } - /// The worker's success path: records the guest-visible value and - /// publishes a success signal. - fn succeed(&self, value: CallReturn) { + /// Publishes a terminal operation while preserving a guest-visible value + /// (including an error that must still retire a close target). + fn complete(&self, value: VmResult) { *self .value .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Ok(value)); + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(value); self.publish(Ok(())); } + + /// The worker's success path: records the guest-visible value and + /// publishes a success signal. + fn succeed(&self, value: CallReturn) { + self.complete(Ok(value)); + } +} + +impl Drop for IoOpShared { + fn drop(&mut self) { + let handle = self + .opened + .get_mut() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(handle) = handle { + let _ = close_io_handle(handle); + } + } } /// A concrete [`HostOperation`] driver for one pending IO operation. @@ -356,141 +585,89 @@ impl Drop for IoOpDriver { } } -/// Cancels one pending builtin IO operation through the execution scope. -pub(crate) fn cancel_pending_op(vm: &mut Vm, op_id: HostOpId) { - let Ok(id) = OperationId::from_raw(op_id) else { - return; - }; - // Drop the completion mailbox from the adapter-declared scope state; the - // operation's driver is cancelled through the registry (which forwards to - // the driver's `cancel`). - if let Ok(state) = io_mailbox(vm) { - state.pending_results.remove(&op_id); - } - let _ = vm - .execution_scope() - .cancel_operation(id, OperationCancelReason::Requested); -} - -/// Polls one pending builtin IO operation through the execution scope's -/// operation registry, delivering the worker's guest-visible value. -pub(crate) fn poll_builtin_io_op( +/// Completes one operation after the generic scope registry reports a +/// terminal outcome. The adapter owns the mailbox and any resource-table +/// mutation; the VM only invokes this opaque completion hook. +fn finish_io_operation( vm: &mut Vm, - op_id: HostOpId, - cx: &mut Context<'_>, -) -> Poll> { - let id = match OperationId::from_raw(op_id) { - Ok(id) => id, - Err(error) => { - return Poll::Ready(Err(VmError::HostError(format!( - "invalid builtin io op {op_id}: {error}" - )))); - } - }; - - let poll_result = vm.execution_scope().poll_operation(id, cx); - match poll_result { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(error)) => { - // Drop the completion mailbox from the adapter-declared scope - // state; the operation itself already failed terminal. - if let Ok(state) = io_mailbox(vm) { - state.pending_results.remove(&op_id); - } - Poll::Ready(Err(VmError::HostError(format!( - "builtin io op {op_id} failed: {error}" - )))) + op_id: OperationId, + outcome: OperationOutcome, + shared: Arc, +) -> VmResult { + if matches!(outcome, OperationOutcome::Cancelled(_)) || shared.cancelled.load(Ordering::Acquire) + { + // The completion hook can be discarded after cancellation. Clean up + // an opened child here as well as in `IoOpShared::drop`, so ownership + // is released as soon as the worker has quiesced. + if let Some(handle) = shared + .opened + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + let _ = close_io_handle(handle); } - Poll::Ready(Ok(outcome)) => { - // The worker wrote the authoritative guest-visible result into - // the completion mailbox before signalling terminal. Extract the - // mailbox entry (an `Arc`) so the scope borrow ends before the - // resource insertion below re-borrows the scope. - let shared = match io_mailbox(vm) { - Ok(state) => { - let Some(shared) = state.pending_results.remove(&op_id) else { - return Poll::Ready(Err(VmError::HostError(format!( - "builtin io op {op_id} has no completion mailbox" - )))); - }; - shared - } - Err(error) => { - return Poll::Ready(Err(VmError::HostError(format!( - "builtin io op {op_id} mailbox unavailable: {error}" - )))); - } - }; - if matches!( - outcome, - crate::vm::operation::driver::OperationOutcome::Cancelled(_) - ) || shared.cancelled.load(Ordering::Acquire) - { - return Poll::Ready(Err(VmError::HostError( - "IO operation cancelled".to_string(), - ))); - } - - // An opened handle (io::open / io::popen) becomes a typed IO - // resource in the scope; the script-visible handle is its raw - // resource token. - if let Some(handle) = shared - .opened - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take() - { - let token = match vm.execution_scope().push_resource(IoResource::new(handle)) { - Ok(token) => token, - Err(error) => { - return Poll::Ready(Err(VmError::HostError(format!( - "builtin io op {op_id} resource insert failed: {error}" - )))); - } - }; - *shared - .value - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - Some(Ok(CallReturn::one(Value::Int(token.handle().raw() as i64)))); - } + return Err(VmError::HostError("IO operation cancelled".to_string())); + } - // A closed handle (io::close) retires the exact resource entry - // through the generic scope close (exact-once). - if let Some(target) = shared - .target - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take() - { - let _ = vm - .execution_scope() - .close_resource::(target, ResourceCloseReason::Requested); - } + // An opened handle (io::open / io::popen) becomes a typed IO resource in + // the scope; the script-visible handle is its raw resource token. The + // resource state's drop guard closes the handle if table admission fails. + if let Some(handle) = shared + .opened + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + let resource = IoResource::new(handle); + let token = vm + .execution_scope() + .push_resource(resource) + .map_err(|error| { + VmError::HostError(format!( + "scoped operation {} resource insert failed: {error}", + op_id.raw() + )) + })?; + *shared + .value + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + Some(Ok(CallReturn::one(Value::Int(token.handle().raw() as i64)))); + } - let value = shared - .value - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take(); - match value { - Some(value) => Poll::Ready(value), - None => Poll::Ready(Err(VmError::HostError(format!( - "builtin io op {op_id} completed without a result" - )))), - } + // A closed handle (io::close) retires the exact resource entry through + // the generic scope close (exact-once). A close operation is successful + // only once both the underlying handle and the scope entry are retired. + if let Some(target) = shared + .target + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + let progress = vm + .execution_scope() + .close_resource::(target, ResourceCloseReason::Requested) + .map_err(VmError::ExecutionScope)?; + if progress != CloseProgress::Ready { + return Err(VmError::HostError(format!( + "scoped operation {} resource retirement remained pending", + op_id.raw() + ))); } } -} -/// Returns the adapter-declared IO scope state (the per-op completion -/// mailbox), creating the empty default on first access while the scope is -/// Active. The state is owned by the execution-scope arena, so it is -/// destroyed with the scope on reset and recreated lazily on next use. -fn io_mailbox(vm: &mut Vm) -> VmResult<&mut IoState> { - vm.execution_scope() - .scope_state_or_insert_with(IoState::default) - .map_err(|error| VmError::HostError(format!("io scope state unavailable: {error}"))) + let value = shared + .value + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + value.ok_or_else(|| { + VmError::HostError(format!( + "scoped operation {} completed without a result", + op_id.raw() + )) + })? } /// Maximum UTF-8 byte length passed to `thread::Builder::name` for an IO @@ -546,6 +723,15 @@ fn schedule_io_task( worker_name )) })?; + if let Err(error) = vm.register_scoped_operation_completion(op_id, { + let completion_shared = Arc::clone(&shared); + move |vm, outcome| finish_io_operation(vm, op_id, outcome, completion_shared) + }) { + let _ = vm + .execution_scope() + .abort_operation(op_id, OperationCancelReason::Requested); + return Err(error); + } let raw = op_id.raw(); let thread_name = io_worker_thread_name(&worker_name); @@ -567,16 +753,27 @@ fn schedule_io_task( .map_err(|error| { // Roll back the registered operation so no orphaned op lingers. shared.mark_worker_done(); + vm.discard_scoped_operation_completion(op_id); let _ = vm .execution_scope() .abort_operation(op_id, OperationCancelReason::Requested); VmError::HostError(format!("failed to spawn io task: {error}")) })?; - io_mailbox(vm)?.pending_results.insert(raw, shared); Ok(raw) } +fn finish_io_worker(shared: &IoOpShared, handle: IoHandleLease, result: VmResult) { + let result = match handle.restore() { + Ok(()) => result, + Err(error) => Err(error), + }; + match result { + Ok(value) => shared.succeed(value), + Err(error) => shared.fail(error), + } +} + /// Opens a file handle for runtime I/O. #[pd_host_function(name = "io::open")] pub(super) fn builtin_io_open( @@ -664,26 +861,31 @@ pub(super) fn builtin_io_popen( return; } }; - let child_pid = child.id(); + let child_guard = SpawnedChildGuard::new(child); + let child_pid = child_guard.id(); shared.install_cancel_hook(move || terminate_process_tree(child_pid)); let handle = match mode.as_str() { "r" => { - if child.stdout.is_none() { + if child_guard.stdout_is_none() { let err = VmError::HostError("io_popen('r') did not provide stdout pipe".to_string()); shared.fail(err); return; } - IoHandle::PopenRead { child } + IoHandle::PopenRead { + child: child_guard.into_child(), + } } "w" => { - if child.stdin.is_none() { + if child_guard.stdin_is_none() { let err = VmError::HostError("io_popen('w') did not provide stdin pipe".to_string()); shared.fail(err); return; } - IoHandle::PopenWrite { child } + IoHandle::PopenWrite { + child: child_guard.into_child(), + } } _ => unreachable!("mode validated above"), }; @@ -709,43 +911,27 @@ pub(super) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult file .read_to_string(&mut out) .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}"))) .map(|_| CallReturn::one(Value::string(out))), - IoHandle::PopenRead { child } => { - let stdout = match child.stdout.as_mut() { - Some(stdout) => stdout, - None => { - resource.restore_handle(handle); - let err = VmError::HostError( - "io_read_all popen handle missing stdout".to_string(), - ); - shared.fail(err); - return; - } - }; - stdout + IoHandle::PopenRead { child } => match child.stdout.as_mut() { + Some(stdout) => stdout .read_to_string(&mut out) .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}"))) - .map(|_| CallReturn::one(Value::string(out))) - } + .map(|_| CallReturn::one(Value::string(out))), + None => Err(VmError::HostError( + "io_read_all popen handle missing stdout".to_string(), + )), + }, IoHandle::PopenWrite { .. } => Err(VmError::HostError( "io_read_all requires a readable handle".to_string(), )), }; - resource.restore_handle(handle); - match result { - Ok(value) => { - shared.succeed(value); - } - Err(err) => { - shared.fail(err); - } - } + finish_io_worker(shared, handle, result); })?; Ok(HostCallResult::Pending(op_id)) } @@ -766,38 +952,24 @@ pub(super) fn builtin_io_read_line( return; } }; - install_process_cancel_hook(shared, &handle); - let result = match &mut handle { + install_process_cancel_hook(shared, &handle, &resource.state); + let result = match &mut *handle { IoHandle::File(file) => { read_line_from_reader(file).map(|line| CallReturn::one(Value::string(line))) } - IoHandle::PopenRead { child } => { - let stdout = match child.stdout.as_mut() { - Some(stdout) => stdout, - None => { - resource.restore_handle(handle); - let err = VmError::HostError( - "io_read_line popen handle missing stdout".to_string(), - ); - shared.fail(err); - return; - } - }; - read_line_from_reader(stdout).map(|line| CallReturn::one(Value::string(line))) - } + IoHandle::PopenRead { child } => match child.stdout.as_mut() { + Some(stdout) => { + read_line_from_reader(stdout).map(|line| CallReturn::one(Value::string(line))) + } + None => Err(VmError::HostError( + "io_read_line popen handle missing stdout".to_string(), + )), + }, IoHandle::PopenWrite { .. } => Err(VmError::HostError( "io_read_line requires a readable handle".to_string(), )), }; - resource.restore_handle(handle); - match result { - Ok(value) => { - shared.succeed(value); - } - Err(err) => { - shared.fail(err); - } - } + finish_io_worker(shared, handle, result); })?; Ok(HostCallResult::Pending(op_id)) } @@ -828,41 +1000,26 @@ pub(super) fn builtin_io_write( return; } }; - install_process_cancel_hook(shared, &handle); - let result = match &mut handle { + install_process_cancel_hook(shared, &handle, &resource.state); + let result = match &mut *handle { IoHandle::File(file) => file .write(&bytes) .map_err(|err| VmError::HostError(format!("io_write failed: {err}"))) .map(|written| CallReturn::one(Value::Int(written as i64))), - IoHandle::PopenWrite { child } => { - let stdin = match child.stdin.as_mut() { - Some(stdin) => stdin, - None => { - resource.restore_handle(handle); - let err = - VmError::HostError("io_write popen handle missing stdin".to_string()); - shared.fail(err); - return; - } - }; - stdin + IoHandle::PopenWrite { child } => match child.stdin.as_mut() { + Some(stdin) => stdin .write(&bytes) .map_err(|err| VmError::HostError(format!("io_write failed: {err}"))) - .map(|written| CallReturn::one(Value::Int(written as i64))) - } + .map(|written| CallReturn::one(Value::Int(written as i64))), + None => Err(VmError::HostError( + "io_write popen handle missing stdin".to_string(), + )), + }, IoHandle::PopenRead { .. } => Err(VmError::HostError( "io_write requires a writable handle".to_string(), )), }; - resource.restore_handle(handle); - match result { - Ok(value) => { - shared.succeed(value); - } - Err(err) => { - shared.fail(err); - } - } + finish_io_worker(shared, handle, result); })?; Ok(HostCallResult::Pending(op_id)) } @@ -880,39 +1037,24 @@ pub(super) fn builtin_io_flush(vm: &mut Vm, handle_id: i64) -> VmResult file .flush() .map_err(|err| VmError::HostError(format!("io_flush failed: {err}"))) .map(|_| CallReturn::one(Value::Bool(true))), - IoHandle::PopenWrite { child } => { - let stdin = match child.stdin.as_mut() { - Some(stdin) => stdin, - None => { - resource.restore_handle(handle); - let err = - VmError::HostError("io_flush popen handle missing stdin".to_string()); - shared.fail(err); - return; - } - }; - stdin + IoHandle::PopenWrite { child } => match child.stdin.as_mut() { + Some(stdin) => stdin .flush() .map_err(|err| VmError::HostError(format!("io_flush failed: {err}"))) - .map(|_| CallReturn::one(Value::Bool(true))) - } + .map(|_| CallReturn::one(Value::Bool(true))), + None => Err(VmError::HostError( + "io_flush popen handle missing stdin".to_string(), + )), + }, IoHandle::PopenRead { .. } => Ok(CallReturn::one(Value::Bool(true))), }; - resource.restore_handle(handle); - match result { - Ok(value) => { - shared.succeed(value); - } - Err(err) => { - shared.fail(err); - } - } + finish_io_worker(shared, handle, result); })?; Ok(HostCallResult::Pending(op_id)) } @@ -925,8 +1067,8 @@ pub(super) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult { - install_process_cancel_hook(shared, &handle); - close_io_handle(handle) + install_process_cancel_hook(shared, &handle, &resource.state); + handle.close() } None => Err(VmError::HostError( "io_close handle is already closing".to_string(), @@ -937,12 +1079,8 @@ pub(super) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult { - shared.succeed(CallReturn::one(Value::Bool(true))); - } - Err(err) => { - shared.fail(err); - } + Ok(()) => shared.succeed(CallReturn::one(Value::Bool(true))), + Err(error) => shared.complete(Err(error)), } })?; Ok(HostCallResult::Pending(op_id)) @@ -962,39 +1100,163 @@ pub(super) fn builtin_io_exists(vm: &mut Vm, path: &str) -> VmResult, +} + +impl SpawnedChildGuard { + fn new(child: Child) -> Self { + Self { child: Some(child) } + } + + fn id(&self) -> u32 { + self.child.as_ref().expect("child guard owns a child").id() + } + + fn stdout_is_none(&self) -> bool { + self.child + .as_ref() + .expect("child guard owns a child") + .stdout + .is_none() + } + + fn stdin_is_none(&self) -> bool { + self.child + .as_ref() + .expect("child guard owns a child") + .stdin + .is_none() + } + + fn into_child(mut self) -> Child { + self.child.take().expect("child guard owns a child") + } +} + +impl Drop for SpawnedChildGuard { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = terminate_child_tree(child); + } + } +} + +fn install_process_cancel_hook( + shared: &IoOpShared, + handle: &IoHandle, + state: &Arc, +) { let pid = match handle { IoHandle::PopenRead { child } | IoHandle::PopenWrite { child } => child.id(), IoHandle::File(_) => return, }; - shared.install_cancel_hook(move || terminate_process_tree(pid)); + let state = Arc::clone(state); + shared.install_cancel_hook(move || { + // A cancelled process operation has already invalidated the process + // stream. Marking the resource closed makes the worker lease reap the + // child instead of restoring a killed, unreaped Child. + state.mark_closed(); + terminate_process_tree(pid); + }); } fn terminate_process_tree(pid: u32) { + let _ = terminate_process_tree_result(pid); +} + +fn terminate_process_tree_result(pid: u32) -> std::io::Result<()> { #[cfg(unix)] { - let Ok(pid) = libc::pid_t::try_from(pid) else { - return; - }; + let pid = libc::pid_t::try_from(pid).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid child pid") + })?; // `spawn_shell_command` puts the shell in its own process group, so a // negative pid terminates the shell and descendants without touching // the VM process group. - unsafe { - libc::kill(-pid, libc::SIGKILL); + let result = unsafe { libc::kill(-pid, libc::SIGKILL) }; + if result == 0 { + Ok(()) + } else { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(()) + } else { + Err(error) + } } } #[cfg(windows)] { - let _ = Command::new("taskkill") + let status = Command::new("taskkill") .args(["/T", "/F", "/PID", &pid.to_string()]) - .status(); + .status()?; + if status.success() { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("taskkill exited with {status}"), + )) + } } #[cfg(not(any(unix, windows)))] { let _ = pid; + Ok(()) } } +/// Terminates a child and reaps it. The only `wait` below is reached after a +/// tree termination signal and a direct leader kill have been attempted; an +/// already exited child is reaped by `try_wait` instead. +fn terminate_child_tree(child: &mut Child) -> VmResult<()> { + let tree_error = terminate_process_tree_result(child.id()).err(); + let status = child + .try_wait() + .map_err(|error| VmError::HostError(format!("io_close popen status failed: {error}")))?; + if status.is_some() { + return Ok(()); + } + + let mut reaped = false; + let direct_error = match child.kill() { + Ok(()) => None, + Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => { + if child + .try_wait() + .map_err(|status_error| { + VmError::HostError(format!("io_close popen status failed: {status_error}")) + })? + .is_some() + { + reaped = true; + None + } else { + Some(error) + } + } + Err(error) => Some(error), + }; + if let Some(error) = direct_error { + return Err(VmError::HostError(format!( + "io_close popen terminate failed: {error}" + ))); + } + + if !reaped { + child + .wait() + .map_err(|error| VmError::HostError(format!("io_close popen wait failed: {error}")))?; + } + if let Some(error) = tree_error { + return Err(VmError::HostError(format!( + "io_close popen process-tree terminate failed: {error}" + ))); + } + Ok(()) +} + fn spawn_shell_command(command: &str, mode: &str) -> VmResult { let mut process = if cfg!(windows) { let mut cmd = Command::new("cmd"); @@ -1051,13 +1313,12 @@ fn io_resource_for_handle( .map_err(|error| { VmError::HostError(format!("io handle {handle_id} borrow failed: {error}")) })?; - // Clone the shared cells so the worker can take/restore the handle while - // the resource itself stays in the scope table. + // Clone the shared resource state so the worker can take/restore the + // handle while the resource itself stays in the scope table. Ok(( handle, Arc::new(IoResource { - handle: Arc::clone(&resource.handle), - closed: Arc::clone(&resource.closed), + state: Arc::clone(&resource.state), }), )) } @@ -1124,22 +1385,15 @@ fn canonicalize_blocking_target(path: &Path) -> VmResult { fn close_io_handle(mut handle: IoHandle) -> VmResult<()> { match &mut handle { - IoHandle::File(file) => { - file.flush().ok(); - } - IoHandle::PopenRead { child } => { - child - .wait() - .map_err(|err| VmError::HostError(format!("io_close popen wait failed: {err}")))?; - } + IoHandle::File(file) => file + .flush() + .map_err(|err| VmError::HostError(format!("io_close flush failed: {err}"))), + IoHandle::PopenRead { child } => terminate_child_tree(child), IoHandle::PopenWrite { child } => { let _ = child.stdin.take(); - child - .wait() - .map_err(|err| VmError::HostError(format!("io_close popen wait failed: {err}")))?; + terminate_child_tree(child) } } - Ok(()) } fn read_line_from_reader(reader: &mut impl Read) -> VmResult { @@ -1206,4 +1460,320 @@ mod tests { } assert!(matches!(driver.poll(&mut cx), Poll::Ready(Ok(())))); } + + #[test] + fn io_resource_close_stays_pending_while_worker_owns_handle() { + let path = std::env::temp_dir().join(format!( + "pd-vm-blocking-io-resource-close-{}", + std::process::id() + )); + let file = std::fs::File::create(&path).expect("test file should open"); + let mut resource = IoResource::new(IoHandle::File(file)); + let worker_handle = resource.take_handle().expect("worker should take handle"); + let close = resource + .begin_close(ResourceCloseReason::Requested) + .expect("begin close should succeed"); + assert_eq!(close, CloseProgress::Pending); + + let wake_count = Arc::new(AtomicUsize::new(0)); + struct CloseWake(Arc); + impl std::task::Wake for CloseWake { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + + fn wake_by_ref(self: &Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let waker = Waker::from(Arc::new(CloseWake(Arc::clone(&wake_count)))); + let mut cx = Context::from_waker(&waker); + assert!(matches!( + HostResource::poll_close(&mut resource, &mut cx), + Poll::Pending + )); + drop(worker_handle); + assert_eq!(wake_count.load(Ordering::SeqCst), 1); + + assert!(matches!( + HostResource::poll_close(&mut resource, &mut cx), + Poll::Ready(Ok(())) + )); + let _ = std::fs::remove_file(path); + } + + #[test] + fn io_resource_worker_release_after_close_does_not_restore_handle() { + let path = std::env::temp_dir().join(format!( + "pd-vm-blocking-io-resource-worker-close-{}", + std::process::id() + )); + let file = std::fs::File::create(&path).expect("test file should open"); + let mut resource = IoResource::new(IoHandle::File(file)); + let worker_handle = resource.take_handle().expect("worker should take handle"); + assert_eq!( + resource + .begin_close(ResourceCloseReason::Requested) + .expect("begin close should succeed"), + CloseProgress::Pending + ); + + worker_handle + .restore() + .expect("worker cleanup after close should succeed"); + assert_eq!(resource.state.active_workers.load(Ordering::Acquire), 0); + assert!( + resource + .state + .handle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_none() + ); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + HostResource::poll_close(&mut resource, &mut cx), + Poll::Ready(Ok(())) + )); + let _ = std::fs::remove_file(path); + } + + #[cfg(unix)] + struct ProcessTreeCleanup { + leader: u32, + descendant: i32, + marker: PathBuf, + } + + #[cfg(unix)] + impl Drop for ProcessTreeCleanup { + fn drop(&mut self) { + terminate_process_tree(self.leader); + unsafe { + libc::kill(self.descendant, libc::SIGKILL); + } + let _ = std::fs::remove_file(&self.marker); + } + } + + #[cfg(unix)] + fn live_popen_for_test() -> (SpawnedChildGuard, PathBuf, i32) { + static TEST_PROCESS_COUNTER: AtomicUsize = AtomicUsize::new(0); + let suffix = TEST_PROCESS_COUNTER.fetch_add(1, Ordering::Relaxed); + let marker = std::env::temp_dir().join(format!( + "pd-vm-blocking-io-popen-{0}-{suffix}.marker", + std::process::id() + )); + let command = format!( + r#"sleep 30 & child=$!; printf '%s\n' "$child" > '{}'; wait "$child""#, + marker.display() + ); + let child = spawn_shell_command(&command, "r").expect("test popen should spawn"); + let guard = SpawnedChildGuard::new(child); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let descendant = loop { + if let Ok(contents) = std::fs::read_to_string(&marker) + && let Ok(pid) = contents.trim().parse::() + { + break pid; + } + assert!( + std::time::Instant::now() < deadline, + "popen test child did not publish its descendant marker" + ); + std::thread::yield_now(); + }; + (guard, marker, descendant) + } + + #[cfg(unix)] + fn process_is_running(pid: i32) -> bool { + let path = format!("/proc/{pid}/stat"); + let Ok(stat) = std::fs::read_to_string(path) else { + return false; + }; + let Some((_, state)) = stat.split_once(") ") else { + return true; + }; + !state.starts_with('Z') + } + + #[cfg(unix)] + fn wait_for_process_exit(pid: i32) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while process_is_running(pid) { + assert!( + std::time::Instant::now() < deadline, + "popen descendant remained alive after process-tree close" + ); + std::thread::yield_now(); + } + } + + #[cfg(unix)] + #[test] + fn closing_live_popen_terminates_and_reaps_the_process_tree() { + let (child, marker, descendant) = live_popen_for_test(); + let _cleanup = ProcessTreeCleanup { + leader: child.id(), + descendant, + marker: marker.clone(), + }; + close_io_handle(IoHandle::PopenRead { + child: child.into_child(), + }) + .expect("closing a live popen must terminate and reap it"); + wait_for_process_exit(descendant); + let _ = std::fs::remove_file(marker); + } + + #[cfg(unix)] + #[test] + fn failed_worker_lease_drop_terminates_and_reaps_process_tree() { + let (child, marker, descendant) = live_popen_for_test(); + let leader = child.id(); + let _cleanup = ProcessTreeCleanup { + leader, + descendant, + marker: marker.clone(), + }; + let resource = IoResource::new(IoHandle::PopenRead { + child: child.into_child(), + }); + let worker_handle = resource.take_handle().expect("worker should take handle"); + drop(worker_handle); + wait_for_process_exit(descendant); + let _ = std::fs::remove_file(marker); + } + + #[cfg(unix)] + #[test] + fn failed_resource_handoff_terminates_and_reaps_opened_process_tree() { + let (child, marker, descendant) = live_popen_for_test(); + let _cleanup = ProcessTreeCleanup { + leader: child.id(), + descendant, + marker: marker.clone(), + }; + let mut vm = Vm::new(crate::Program::new( + Vec::new(), + vec![crate::OpCode::Ret as u8], + )); + let shared = Arc::new(IoOpShared::new()); + *shared + .opened + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(IoHandle::PopenRead { + child: child.into_child(), + }); + let op_id = vm + .execution_scope() + .start_operation(OperationSpec::new(IoOpDriver::new( + Arc::clone(&shared), + "io::test-handoff", + ))) + .expect("test operation should start"); + vm.execution_scope() + .begin_close(ResourceCloseReason::Requested) + .expect("scope should start closing"); + let error = finish_io_operation(&mut vm, op_id, OperationOutcome::Completed, shared) + .expect_err("resource insertion into a closing scope must fail"); + assert!(error.to_string().contains("resource insert failed")); + wait_for_process_exit(descendant); + let _ = std::fs::remove_file(marker); + } + + #[test] + fn close_completion_does_not_report_success_while_resource_retirement_is_pending() { + let path = std::env::temp_dir().join(format!( + "pd-vm-blocking-io-close-pending-{}", + std::process::id() + )); + let file = std::fs::File::create(&path).expect("test file should open"); + let resource = IoResource::new(IoHandle::File(file)); + let worker_resource = IoResource { + state: Arc::clone(&resource.state), + }; + let mut vm = Vm::new(crate::Program::new( + Vec::new(), + vec![crate::OpCode::Ret as u8], + )); + let token = vm + .execution_scope() + .push_resource(resource) + .expect("resource should insert"); + let worker_handle = worker_resource + .take_handle() + .expect("worker should take handle"); + let shared = Arc::new(IoOpShared::new()); + *shared + .target + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(token.handle()); + *shared + .value + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + Some(Ok(CallReturn::one(Value::Bool(true)))); + let op_id = vm + .execution_scope() + .start_operation(OperationSpec::new(IoOpDriver::new( + Arc::clone(&shared), + "io::test-close-pending", + ))) + .expect("test operation should start"); + + let error = finish_io_operation(&mut vm, op_id, OperationOutcome::Completed, shared) + .expect_err("pending resource retirement must not report success"); + assert!(error.to_string().contains("remained pending")); + + worker_handle + .restore() + .expect("worker cleanup after close should succeed"); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.execution_scope() + .resources_mut() + .poll_close(token, &mut cx), + Poll::Ready(Ok(())) + )); + let _ = std::fs::remove_file(path); + } + + #[test] + fn close_completion_reports_scope_retirement_errors() { + let mut vm = Vm::new(crate::Program::new( + Vec::new(), + vec![crate::OpCode::Ret as u8], + )); + let file = std::fs::File::open("Cargo.toml").expect("test file should open"); + let token = vm + .execution_scope() + .push_resource(IoResource::new(IoHandle::File(file))) + .expect("resource should insert"); + vm.execution_scope() + .close_resource::(token.handle(), ResourceCloseReason::Requested) + .expect("initial close should retire resource"); + + let shared = Arc::new(IoOpShared::new()); + *shared + .target + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(token.handle()); + *shared + .value + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + Some(Ok(CallReturn::one(Value::Bool(true)))); + let op_id = vm + .execution_scope() + .start_operation(OperationSpec::new(IoOpDriver::new( + Arc::clone(&shared), + "io::test-close", + ))) + .expect("test operation should start"); + let error = finish_io_operation(&mut vm, op_id, OperationOutcome::Completed, shared) + .expect_err("stale scope retirement must be visible to the caller"); + assert!(error.to_string().contains("execution scope")); + } } diff --git a/src/builtins/runtime/io/mod.rs b/src/builtins/runtime/io/mod.rs index 8074a495..8f2ef78e 100644 --- a/src/builtins/runtime/io/mod.rs +++ b/src/builtins/runtime/io/mod.rs @@ -1,19 +1,22 @@ -//! IO builtin host implementation. +//! IO builtin host implementation, selected by feature: //! -//! At this layer IO is blocking-only: it drives IO through worker threads -//! registered as concrete [`HostOperation`] drivers in the execution scope. -//! Live handles are [`IoResource`]s owned by the VM's execution scope and -//! in-flight IO work is driven by concrete operation drivers registered in -//! the same scope. +//! - `async` (non-wasm32): [`async_io`] drives IO through tokio and submits +//! async host functions via the generic async host bridge. +//! - default (non-wasm32): [`blocking`] drives IO through worker threads +//! registered as concrete [`HostOperation`] drivers in the execution scope. +//! - wasm32: the wasm stub implementation. //! -//! The capability system (restricted registries and explicit grants) is -//! introduced by the public host SDK layer; before that layer exists, -//! [`io_policy`] returns only the configured persistent [`IoPolicy`] held in -//! the generic module-state store. +//! Both non-wasm32 implementations share the same execution-scope resource +//! model: live handles are [`IoResource`]s owned by the VM's execution scope +//! and in-flight IO work is driven by concrete operation drivers registered +//! in the same scope. Only the concurrency mechanism differs. use super::borrow_arg; +#[cfg(all(feature = "async", not(target_arch = "wasm32")))] +use super::{CallOutcome, CaptureAsyncHostContext, return_one}; use crate::vm::Vm; +#[cfg(all(not(feature = "async"), not(target_arch = "wasm32")))] pub(super) use super::HostCallResult; #[derive(Clone, Debug, PartialEq, Eq)] @@ -49,8 +52,8 @@ impl IoHostExt for Vm { policy.allowed_roots.dedup(); // Adapter-declared policy stored in the generic module-state store: // module-level policy survives execution-scope reset (an embedder's - // roots remain in force across `reset_for_reuse`), while the adapter's - // per-invocation runtime state lives in the scope arena. + // roots/capabilities remain in force across `reset_for_reuse`), while + // the adapter's per-invocation runtime state lives in the scope arena. self.host.set_module_state(policy); } @@ -60,12 +63,20 @@ impl IoHostExt for Vm { } pub(super) fn io_policy(vm: &Vm) -> Option { - vm.host.get_module_state::().cloned() + vm.host + .get_module_state::() + .cloned() + .or_else(|| (!vm.host.default_builtin_capabilities_enabled()).then(IoPolicy::default)) } +#[cfg(all(feature = "async", not(target_arch = "wasm32")))] +mod async_io; +#[cfg(all(not(feature = "async"), not(target_arch = "wasm32")))] +mod blocking; + #[cfg(target_arch = "wasm32")] pub(super) use super::io_wasm::*; -#[cfg(not(target_arch = "wasm32"))] -mod blocking; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "async", not(target_arch = "wasm32")))] +pub(crate) use async_io::*; +#[cfg(all(not(feature = "async"), not(target_arch = "wasm32")))] pub(crate) use blocking::*; diff --git a/src/builtins/runtime/io_wasm.rs b/src/builtins/runtime/io_wasm.rs index 651daad6..8460be3e 100644 --- a/src/builtins/runtime/io_wasm.rs +++ b/src/builtins/runtime/io_wasm.rs @@ -1,25 +1,7 @@ -use std::task::{Context, Poll}; - use pd_host_function::pd_host_function; use super::HostCallResult; -use crate::vm::{CallReturn, HostOpId, Vm, VmError, VmResult}; - -/// There are no pending native I/O workers on wasm32. The generic VM -/// cancellation hook still calls into the selected I/O backend, so keep the -/// wasm implementation a deliberate no-op with the same feature-neutral -/// signature as the native backends. -pub(super) fn cancel_pending_op(_vm: &mut Vm, _op_id: HostOpId) {} - -pub(super) fn poll_builtin_io_op( - _vm: &mut Vm, - op_id: HostOpId, - _cx: &mut Context<'_>, -) -> Poll> { - Poll::Ready(Err(VmError::HostError(format!( - "builtin io op {op_id} is unsupported on wasm32 runtime", - )))) -} +use crate::vm::{Vm, VmError, VmResult}; /// Opens a file handle for runtime I/O. #[pd_host_function(name = "io::open")] diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index 715a3c86..77b1b9c7 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -1,14 +1,19 @@ // VM-side builtin execution entrypoints. // Builtin metadata and call-index mapping live in crate::builtins. -use std::task::{Context, Poll}; use crate::builtins::BuiltinFunction; +#[cfg(feature = "async")] +use crate::vm::CaptureAsyncHostContext; #[allow(unused_imports)] use crate::vm::{CallOutcome, CallReturn, HostOpId, Value, Vm, VmError, VmResult}; mod aot; mod bytes; +pub(crate) mod context; +mod context_host; pub(crate) mod core; +pub(crate) mod error; +pub(crate) mod event; mod host; #[cfg(not(target_arch = "wasm32"))] mod io; @@ -22,17 +27,27 @@ pub(crate) mod print; pub(crate) mod regex; #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] pub(crate) mod sqlite; +pub(crate) mod standard_composition; mod typed; #[cfg(target_arch = "wasm32")] use io_wasm as io; +#[allow(unused_imports)] +pub(crate) use context::{RuntimeContext, RuntimeContextConfig, STREAM_EMIT_NAME}; +#[allow(unused_imports)] +pub use error::{RuntimeError, RuntimeErrorCode, RuntimeResult}; +#[allow(unused_imports)] +pub(crate) use event::{EventLimits, EventPayload}; #[cfg(not(target_arch = "wasm32"))] pub use io::{IoHostExt, IoPolicy}; +pub use standard_composition::standard_composition; pub use typed::HostCallResult; -use typed::{ - AnyValue, IntoBuiltinCallOutcome, IntoHostCallOutcome, NumberValue, UnknownValue, VmArray, - VmBytes, VmMap, arg, borrow_arg, return_none, return_one, take_arg, +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, }; pub(crate) enum BuiltinCallOutcome { @@ -128,49 +143,6 @@ pub(crate) fn execute_builtin_call( } } -pub(crate) fn cancel_builtin_io_op(vm: &mut Vm, op_id: HostOpId) { - io::cancel_pending_op(vm, op_id); -} - -pub(crate) fn poll_builtin_io_op( - vm: &mut Vm, - op_id: HostOpId, - cx: &mut Context<'_>, -) -> Poll> { - io::poll_builtin_io_op(vm, op_id, cx) -} - -/// Cancels one pending SQLite operation. The generic VM (feature-neutral) -/// calls this hook unconditionally; on builds without the SQLite adapter the -/// delegation below is compiled out and the call is a no-op. -pub(crate) fn cancel_builtin_sqlite_op(vm: &mut Vm, op_id: HostOpId) { - #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] - sqlite::cancel_pending_op(vm, op_id); - #[cfg(not(all(feature = "sqlite", not(target_arch = "wasm32"))))] - let _ = (vm, op_id); -} - -/// Polls one pending SQLite operation. The generic VM (feature-neutral) calls -/// this hook unconditionally; on builds without the SQLite adapter it reports -/// an unsupported-operations error. -pub(crate) fn poll_builtin_sqlite_op( - vm: &mut Vm, - op_id: HostOpId, - cx: &mut Context<'_>, -) -> Poll> { - #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] - { - sqlite::poll_pending_op(vm, op_id, cx) - } - #[cfg(not(all(feature = "sqlite", not(target_arch = "wasm32"))))] - { - let _ = (vm, cx); - Poll::Ready(Err(VmError::HostError(format!( - "builtin sqlite op {op_id} is unsupported in this build" - )))) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/builtins/runtime/regex.rs b/src/builtins/runtime/regex.rs index b335cf53..1fcdd2bb 100644 --- a/src/builtins/runtime/regex.rs +++ b/src/builtins/runtime/regex.rs @@ -1,4 +1,3 @@ -use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use regex::Regex; @@ -7,94 +6,6 @@ use super::VmArray; use crate::vm::{Value, Vm, VmError, VmResult}; use pd_host_function::pd_host_function; -const DEFAULT_REGEX_CACHE_CAPACITY: usize = 512; - -pub(crate) struct RegexCache { - capacity: usize, - entries: HashMap>, - recency: VecDeque, - compile_count: u64, - hit_count: u64, -} - -impl Default for RegexCache { - fn default() -> Self { - Self::with_capacity(DEFAULT_REGEX_CACHE_CAPACITY) - } -} - -impl RegexCache { - pub(crate) fn with_capacity(capacity: usize) -> Self { - Self { - capacity, - entries: HashMap::new(), - recency: VecDeque::new(), - compile_count: 0, - hit_count: 0, - } - } - - pub(crate) fn get_or_compile(&mut self, pattern: &str) -> Result, regex::Error> { - if let Some(regex) = self.entries.get(pattern).cloned() { - self.hit_count = self.hit_count.saturating_add(1); - self.touch(pattern); - return Ok(regex); - } - - let regex = Arc::new(Regex::new(pattern)?); - self.compile_count = self.compile_count.saturating_add(1); - if self.capacity == 0 { - return Ok(regex); - } - while self.entries.len() >= self.capacity { - let Some(oldest) = self.recency.pop_front() else { - break; - }; - self.entries.remove(&oldest); - } - self.entries.insert(pattern.to_string(), regex.clone()); - self.recency.push_back(pattern.to_string()); - Ok(regex) - } - - fn touch(&mut self, pattern: &str) { - if let Some(index) = self.recency.iter().position(|entry| entry == pattern) { - self.recency.remove(index); - } - self.recency.push_back(pattern.to_string()); - } - - pub(crate) fn capacity(&self) -> usize { - self.capacity - } - - pub(crate) fn set_capacity(&mut self, capacity: usize) { - self.capacity = capacity; - while self.entries.len() > capacity { - let Some(oldest) = self.recency.pop_front() else { - self.entries.clear(); - break; - }; - self.entries.remove(&oldest); - } - if capacity == 0 { - self.recency.clear(); - } - } - - pub(crate) fn len(&self) -> usize { - self.entries.len() - } - - pub(crate) fn compile_count(&self) -> u64 { - self.compile_count - } - - pub(crate) fn hit_count(&self) -> u64 { - self.hit_count - } -} - fn cached_regex(vm: &mut Vm, operation: &str, pattern: &str) -> VmResult> { vm.cached_regex(pattern) .map_err(|err| VmError::HostError(format!("{operation} invalid pattern: {err}"))) @@ -171,6 +82,7 @@ pub(super) fn builtin_re_captures(vm: &mut Vm, pattern: &str, text: &str) -> VmR #[cfg(test)] mod tests { use super::*; + use crate::vm::regex_cache::{DEFAULT_REGEX_CACHE_CAPACITY, RegexCache}; use crate::{OpCode, Program, Vm}; #[test] diff --git a/src/builtins/runtime/sqlite.rs b/src/builtins/runtime/sqlite.rs index 0186c8e1..d89451a3 100644 --- a/src/builtins/runtime/sqlite.rs +++ b/src/builtins/runtime/sqlite.rs @@ -21,7 +21,6 @@ //! transaction statement count, and transaction deadline, plus SQL-safety //! rejection and read-only enforcement. -use std::collections::HashMap; use std::fs; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -42,7 +41,7 @@ use super::{HostCallResult, VmMap}; use crate::vm::operation::driver::HostOperation; use crate::vm::operation::error::{OperationError, OperationErrorCode, OperationResult}; use crate::vm::operation::reason::OperationCancelReason; -use crate::vm::operation::{OperationId, OperationSpec}; +use crate::vm::operation::{OperationId, OperationOutcome, OperationSpec}; use crate::vm::resource::close::{CloseProgress, HostResource}; use crate::vm::resource::error::ResourceResult; use crate::vm::resource::{ResourceCloseReason, ResourceHandle}; @@ -259,7 +258,7 @@ impl Drop for SqliteResource { } /// Shared state between one SQLite worker, its [`SqliteOpDriver`] operation, -/// and [`poll_pending_op`] on the VM thread. +/// and the adapter-owned completion hook on the VM thread. /// /// The worker writes the terminal signal and guest-visible value; the driver /// reflects the signal into the operation registry and the VM wrapper reads @@ -486,7 +485,6 @@ impl Drop for SqliteOpDriver { /// module state stored in the generic `ModuleStateStore`, so it survives /// `reset_for_reuse` while this runtime state does not. pub(crate) struct SqliteState { - pending_results: HashMap>, /// Adapter-owned live connection count, shared with each /// [`SqliteResource`] so `begin_close` can decrement it. Avoids a generic /// by-type close helper. @@ -496,7 +494,6 @@ pub(crate) struct SqliteState { impl Default for SqliteState { fn default() -> Self { Self { - pending_results: HashMap::new(), open_connections: Arc::new(AtomicUsize::new(0)), } } @@ -564,88 +561,27 @@ fn cancellation_message(shared: &SqliteOpShared) -> String { } } -/// Cancels one pending SQLite operation through the execution scope. -pub(super) fn cancel_pending_op(vm: &mut Vm, op_id: HostOpId) { - let Ok(id) = OperationId::from_raw(op_id) else { - return; - }; - // Drop the completion mailbox from the adapter-declared scope state; the - // operation's driver is cancelled through the registry (which forwards to - // the driver's `cancel`). - if let Ok(state) = sqlite_state(vm) { - state.pending_results.remove(&op_id); +/// Completes one operation after the generic scope registry reports a +/// terminal outcome. The adapter owns the mailbox; the VM only invokes this +/// opaque completion hook. +fn finish_sqlite_operation( + _vm: &mut Vm, + op_id: OperationId, + outcome: OperationOutcome, + shared: Arc, +) -> VmResult { + // A cancelled/closed operation reports a guest-visible error even if the + // worker happened to complete concurrently. + if matches!(outcome, OperationOutcome::Cancelled(_)) || shared.is_cancelled() { + return Err(VmError::HostError(cancellation_message(&shared))); } - let _ = vm - .execution_scope() - .cancel_operation(id, OperationCancelReason::Requested); -} - -/// Polls one pending SQLite operation through the execution scope's operation -/// registry, delivering the worker's guest-visible value. -pub(super) fn poll_pending_op( - vm: &mut Vm, - op_id: HostOpId, - cx: &mut Context<'_>, -) -> Poll> { - let id = match OperationId::from_raw(op_id) { - Ok(id) => id, - Err(error) => { - return Poll::Ready(Err(VmError::HostError(format!( - "invalid builtin sqlite op {op_id}: {error}" - )))); - } - }; - - let poll_result = vm.execution_scope().poll_operation(id, cx); - match poll_result { - Poll::Pending => Poll::Pending, - Poll::Ready(Err(error)) => { - // Drop the completion mailbox from the adapter-declared scope - // state; the operation itself already failed terminal. - if let Ok(state) = sqlite_state(vm) { - state.pending_results.remove(&op_id); - } - Poll::Ready(Err(VmError::HostError(format!( - "builtin sqlite op {op_id} failed: {error}" - )))) - } - Poll::Ready(Ok(outcome)) => { - // The worker wrote the authoritative guest-visible result into - // the completion mailbox before signalling terminal. Extract the - // mailbox entry (an `Arc`) so the scope borrow ends before any - // later scope mutation. - let shared = match sqlite_state(vm) { - Ok(state) => { - let Some(shared) = state.pending_results.remove(&op_id) else { - return Poll::Ready(Err(VmError::HostError(format!( - "builtin sqlite op {op_id} has no completion mailbox" - )))); - }; - shared - } - Err(error) => { - return Poll::Ready(Err(VmError::HostError(format!( - "builtin sqlite op {op_id} mailbox unavailable: {error}" - )))); - } - }; - // A cancelled/closed operation reports a guest-visible error even if - // the worker happened to complete concurrently. - if matches!( - outcome, - crate::vm::operation::driver::OperationOutcome::Cancelled(_) - ) || shared.is_cancelled() - { - return Poll::Ready(Err(VmError::HostError(cancellation_message(&shared)))); - } - let value = shared.value.lock().expect("sqlite value lock").take(); - match value { - Some(value) => Poll::Ready(value), - None => Poll::Ready(Err(VmError::HostError(format!( - "builtin sqlite op {op_id} completed without a result" - )))), - } - } + let value = shared.value.lock().expect("sqlite value lock").take(); + match value { + Some(value) => value, + None => Err(VmError::HostError(format!( + "scoped operation {} completed without a result", + op_id.raw() + ))), } } @@ -1303,6 +1239,16 @@ fn schedule_operation( .lock() .expect("sqlite driver id lock should not be poisoned") = Some(op_id); slot.register(op_id); + if let Err(error) = vm.register_scoped_operation_completion(op_id, { + let completion_shared = Arc::clone(&shared); + move |vm, outcome| finish_sqlite_operation(vm, op_id, outcome, completion_shared) + }) { + let _ = vm + .execution_scope() + .abort_operation(op_id, OperationCancelReason::Requested); + slot.unregister(op_id); + return Err(error); + } let raw = op_id.raw(); let worker = thread::Builder::new() @@ -1337,6 +1283,7 @@ fn schedule_operation( }) .map_err(|error| { shared.mark_worker_done(); + vm.discard_scoped_operation_completion(op_id); let _ = vm .execution_scope() .abort_operation(op_id, OperationCancelReason::Requested); @@ -1345,7 +1292,6 @@ fn schedule_operation( })?; shared.set_worker(worker); - sqlite_state(vm)?.pending_results.insert(raw, shared); Ok(raw) } diff --git a/src/builtins/runtime/standard_composition.rs b/src/builtins/runtime/standard_composition.rs new file mode 100644 index 00000000..050ba9a1 --- /dev/null +++ b/src/builtins/runtime/standard_composition.rs @@ -0,0 +1,152 @@ +//! Concrete standard-surface composition for the host-agnostic VM core. +//! +//! This module implements [`StandardSurfaceComposition`] for the same-crate +//! standard builtin layer. It is the *only* place that knows which concrete +//! standard domains exist (`io::`, `http::`, `sqlite::`) and which builtin +//! modules implement them. `src/vm` consumes it through the generic trait and +//! never names a domain, namespace prefix, or feature. +//! +//! The implementation is *caller-provided per-instance state*: the outer +//! standard-runtime constructor installs one instance on the standard +//! [`HostFunctionRegistry`] (and on the `Vm` for the legacy fallback paths) +//! through [`standard_composition`]. There is no process-global slot and no +//! hidden installation from `HostRuntime::new()`. + +use std::sync::Arc; + +use crate::BuiltinFunction; +use crate::bytecode::{HostImport, SharedArray, SharedMap}; +use crate::vm::standard_composition::StandardSurfaceComposition; +use crate::vm::{CallOutcome, HostFunctionRegistry, Value, Vm, VmResult}; + +use super::register_default_host_functions; +use crate::builtins::default_host_callable; + +/// The concrete standard-surface composition for this build. +/// +/// Feature-gated composition happens through the existing standard builtin +/// helpers: IO is always present under `runtime`, HTTP under `http-client`, +/// SQLite under `sqlite`. Required/present/stage is one opaque operation; +/// the VM core never sees a surface mask or count. +#[derive(Debug)] +pub(crate) struct StandardSurfaceCompositionImpl; + +impl StandardSurfaceComposition for StandardSurfaceCompositionImpl { + fn import_in_standard(&self, import: &HostImport) -> bool { + default_host_callable(&import.name).is_some() + } + + fn ensure_surfaces( + &self, + imports: &[HostImport], + registry: &mut HostFunctionRegistry, + ) -> VmResult { + let mut staged = false; + for import in imports { + if default_host_callable(&import.name).is_none() { + continue; + } + // The default host callable is the surface: stage it if the + // registry does not already carry the name. + if !registry.contains_name(&import.name) { + register_default_host_functions(registry); + staged = true; + break; + } + } + Ok(staged) + } + + fn build_default_registry(&self) -> VmResult { + Ok(HostFunctionRegistry::new()) + } + + fn bind_default_name(&self, vm: &mut Vm, name: &str) -> bool { + super::bind_default_host_function(vm, name) + } + + fn execute_builtin_call( + &self, + vm: &mut Vm, + builtin: BuiltinFunction, + args: &mut [Value], + ) -> VmResult { + super::execute_builtin_call(vm, builtin, args).map(|outcome| match outcome { + super::BuiltinCallOutcome::Return(values) => CallOutcome::Return(values), + super::BuiltinCallOutcome::Halt => CallOutcome::Halt, + super::BuiltinCallOutcome::Pending(op_id) => CallOutcome::Pending(op_id), + }) + } + + fn string_contains(&self, text: &str, needle: &str) -> Option { + Some(super::core::builtin_string_contains_impl(text, needle)) + } + + fn string_replace_literal( + &self, + text: &str, + needle: &str, + replacement: &str, + ) -> Option { + Some(super::core::builtin_string_replace_literal_impl( + text, + needle, + replacement, + )) + } + + fn string_lower_ascii(&self, text: &str) -> Option { + Some(super::core::builtin_string_lower_ascii_impl(text)) + } + + fn string_split_literal(&self, text: &str, delimiter: &str) -> Option> { + Some(super::core::builtin_string_split_literal_impl( + text, delimiter, + )) + } + + fn value_to_string(&self, value: &Value) -> Option { + Some(super::core::builtin_to_string_impl(value)) + } + + fn regex_match(&self, vm: &mut Vm, pattern: &str, text: &str) -> VmResult { + super::regex::native_re_match(vm, pattern, text) + } + + fn regex_replace( + &self, + vm: &mut Vm, + pattern: &str, + text: &str, + replacement: &str, + ) -> VmResult { + super::regex::native_re_replace(vm, pattern, text, replacement) + } + + fn ensure_supported_map_key(&self, key: &Value) -> VmResult<()> { + super::core::ensure_supported_map_key(key) + } + + fn set_owned(&self, container: Value, key: Value, value: Value) -> VmResult { + super::core::builtin_set_owned(container, key, value) + } + + fn set_map_shared(&self, entries: SharedMap, key: Value, value: Value) -> Option { + Some(super::core::builtin_set_map_shared_impl( + entries, key, value, + )) + } + + fn array_push_shared(&self, items: SharedArray, value: Value) -> Option { + Some(super::core::builtin_array_push_shared_impl(items, value)) + } +} + +/// Returns a fresh concrete standard-surface composition instance. +/// +/// The outer standard-runtime constructor installs this on the standard +/// registry and on a `Vm` when it wants default standard composition +/// behavior. Each call returns a new instance; there is no shared global. +pub fn standard_composition() -> Arc { + Arc::new(StandardSurfaceCompositionImpl) +} diff --git a/src/builtins/runtime/typed.rs b/src/builtins/runtime/typed.rs index 55612e0e..414b869a 100644 --- a/src/builtins/runtime/typed.rs +++ b/src/builtins/runtime/typed.rs @@ -45,7 +45,7 @@ pub(super) fn missing_arg(label: &str) -> VmError { VmError::HostError(format!("missing argument: {label}")) } -pub(super) trait BorrowVmValue<'a>: Sized { +pub trait BorrowVmValue<'a>: Sized { fn borrow_vm_value(value: &'a Value, label: &str) -> VmResult; fn from_missing_arg(label: &str) -> VmResult { @@ -53,7 +53,7 @@ pub(super) trait BorrowVmValue<'a>: Sized { } } -pub(super) trait FromVmValue<'a>: Sized { +pub trait FromVmValue<'a>: Sized { fn from_vm_value(value: &'a Value, label: &str) -> VmResult; fn from_missing_arg(label: &str) -> VmResult { @@ -74,7 +74,7 @@ where } } -pub(super) trait TakeVmValue: Sized { +pub trait TakeVmValue: Sized { fn take_vm_value(slot: &mut Value, label: &str) -> VmResult; fn from_missing_arg(label: &str) -> VmResult { @@ -82,7 +82,7 @@ pub(super) trait TakeVmValue: Sized { } } -pub(super) fn borrow_arg<'a, T>(args: &'a [Value], index: usize, label: &str) -> VmResult +pub fn borrow_arg<'a, T>(args: &'a [Value], index: usize, label: &str) -> VmResult where T: BorrowVmValue<'a>, { @@ -92,14 +92,14 @@ where } } -pub(super) fn arg<'a, T>(args: &'a [Value], index: usize, label: &str) -> VmResult +pub fn arg<'a, T>(args: &'a [Value], index: usize, label: &str) -> VmResult where T: BorrowVmValue<'a>, { borrow_arg(args, index, label) } -pub(super) fn take_arg(args: &mut [Value], index: usize, label: &str) -> VmResult +pub fn take_arg(args: &mut [Value], index: usize, label: &str) -> VmResult where T: TakeVmValue, { @@ -130,6 +130,15 @@ impl<'a> FromVmValue<'a> for &'a str { } } +impl FromVmValue<'_> for String { + fn from_vm_value(value: &Value, _label: &str) -> VmResult { + match value { + Value::String(text) => Ok(text.to_string()), + _ => Err(VmError::TypeMismatch("string")), + } + } +} + impl<'a> FromVmValue<'a> for &'a [u8] { fn from_vm_value(value: &'a Value, _label: &str) -> VmResult { match value { @@ -157,6 +166,15 @@ impl<'a> FromVmValue<'a> for &'a VmMap { } } +impl FromVmValue<'_> for VmMap { + fn from_vm_value(value: &Value, _label: &str) -> VmResult { + match value { + Value::Map(entries) => Ok(entries.as_ref().clone()), + _ => Err(VmError::TypeMismatch("map")), + } + } +} + impl FromVmValue<'_> for SharedArray { fn from_vm_value(value: &Value, _label: &str) -> VmResult { match value { @@ -308,15 +326,15 @@ where } } -pub(super) trait IntoVmValue { +pub trait IntoVmValue { fn into_vm_value(self) -> Value; } -pub(super) fn return_none() -> CallReturn { +pub fn return_none() -> CallReturn { CallReturn::none() } -pub(super) fn return_one(value: T) -> CallReturn +pub fn return_one(value: T) -> CallReturn where T: IntoVmValue, { @@ -465,7 +483,18 @@ where } } -pub(super) trait IntoHostCallOutcome { +impl IntoBuiltinCallOutcome for CallOutcome { + fn into_builtin_call_outcome(self) -> BuiltinCallOutcome { + match self { + CallOutcome::Return(values) => BuiltinCallOutcome::Return(values), + CallOutcome::Halt => BuiltinCallOutcome::Halt, + CallOutcome::Pending(op_id) => BuiltinCallOutcome::Pending(op_id), + CallOutcome::Yield => unreachable!("async builtin wrappers cannot return Yield"), + } + } +} + +pub trait IntoHostCallOutcome { fn into_host_call_outcome(self) -> CallOutcome; } diff --git a/src/bytecode.rs b/src/bytecode.rs index 23d12a71..69a38839 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -4,11 +4,12 @@ use std::hash::{BuildHasherDefault, Hash, Hasher}; use std::sync::{Arc, OnceLock}; use crate::compiler::TypeSchema; +use crate::host_api::HostImportSchema; /// Bytecode ABI version used for VM-internal cache identity (JIT trace cache, /// program cache keys). The VMBC wire format version lives in `src/vmbc.rs` -/// (`VERSION_V11`); both were bumped together for the static builtin ID break. -pub const BYTECODE_ABI_VERSION: u16 = 11; +/// (`VERSION_V12`); both were bumped together for the static builtin ID break. +pub const BYTECODE_ABI_VERSION: u16 = 12; pub type SharedString = Arc; pub type SharedBytes = Arc>; @@ -619,6 +620,16 @@ pub struct Program { pub code: Vec, pub local_count: usize, pub imports: Vec, + /// Full catalog identity for each import, aligned with [`Self::imports`]. + /// `Program::new` and legacy bytecode decoders leave this empty, which + /// keeps their name/arity bindings available for non-overloaded hosts. + /// + /// This is crate-visible storage rather than a new public struct field: the + /// existing private instruction caches already mean that downstream crates + /// cannot construct `Program` with a struct literal. Keeping this field + /// non-public avoids expanding the public layout while exposing the + /// semantic metadata through [`Self::host_import_schemas`]. + pub(crate) host_import_schemas: Vec>, pub debug: Option, pub type_map: Option, pub script_functions: Vec, @@ -639,6 +650,7 @@ impl Program { code, local_count, imports: Vec::new(), + host_import_schemas: Vec::new(), debug: None, type_map: None, script_functions: Vec::new(), @@ -662,6 +674,7 @@ impl Program { code, local_count, imports: Vec::new(), + host_import_schemas: Vec::new(), debug, type_map: None, script_functions: Vec::new(), @@ -686,6 +699,7 @@ impl Program { code, local_count, imports, + host_import_schemas: Vec::new(), debug, type_map: None, script_functions: Vec::new(), @@ -698,6 +712,51 @@ impl Program { } } + /// Attaches the full selected catalog schema to every host import. + /// + /// The metadata is checked before it is stored so a caller cannot leave a + /// partially aligned program behind. Runtime binding uses this identity + /// rather than reducing an overload to name and arity. + pub fn with_host_import_schemas( + mut self, + schemas: Vec, + ) -> Result { + if schemas.len() != self.imports.len() { + return Err(format!( + "host import schema count {} does not match import count {}", + schemas.len(), + self.imports.len() + )); + } + for (index, (import, schema)) in self.imports.iter().zip(schemas.iter()).enumerate() { + if schema.name != import.name { + return Err(format!( + "host import schema {index} names `{}` but import names `{}`", + schema.name, import.name + )); + } + if schema.arity() != import.arity as usize { + return Err(format!( + "host import schema {index} has arity {} but import has arity {}", + schema.arity(), + import.arity + )); + } + } + self.host_import_schemas = schemas.into_iter().map(Some).collect(); + Ok(self) + } + + /// Returns the complete catalog identity retained for each host import. + /// + /// An empty slice means that the program uses legacy name/arity imports or + /// was built without catalog metadata. When non-empty it is aligned with + /// [`Self::imports`] and retains every parameter, passing mode, nested + /// schema, return schema, and catalog fingerprint. + pub fn host_import_schemas(&self) -> &[Option] { + &self.host_import_schemas + } + pub fn with_local_count(mut self, local_count: usize) -> Self { self.local_count = local_count; self diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index ff77d89f..d51697c3 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use crate::Program; use crate::assembler::AssemblerError; +use crate::host_api::{HostApiCatalog, HostImportSchema, HostTypeSchema}; #[cfg(feature = "runtime")] use crate::vm::Vm; @@ -460,6 +461,30 @@ impl SourceFlavor { } } +fn compiler_type_matches_host(actual: &TypeSchema, expected: &HostTypeSchema) -> bool { + match (actual, expected) { + (TypeSchema::Unknown, _) => true, + (TypeSchema::Null, HostTypeSchema::Null) => true, + (TypeSchema::Int, HostTypeSchema::Int | HostTypeSchema::Number) => true, + (TypeSchema::Float, HostTypeSchema::Float | HostTypeSchema::Number) => true, + (TypeSchema::Number, HostTypeSchema::Number) => true, + (TypeSchema::Bool, HostTypeSchema::Bool) => true, + (TypeSchema::String, HostTypeSchema::String) => true, + (TypeSchema::Bytes, HostTypeSchema::Bytes) => true, + (TypeSchema::Optional(actual), HostTypeSchema::Optional(expected)) => { + compiler_type_matches_host(actual, expected) + } + (TypeSchema::Array(actual), HostTypeSchema::Array(expected)) => { + compiler_type_matches_host(actual, expected) + } + (TypeSchema::Map(actual), HostTypeSchema::Map(expected)) => { + compiler_type_matches_host(actual, expected) + } + (TypeSchema::Callable { .. }, HostTypeSchema::Callable { .. }) => true, + _ => false, + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ReplLocalBinding { pub name: String, @@ -481,6 +506,70 @@ pub struct CompiledProgram { } impl CompiledProgram { + /// Attaches the exact catalog schema selected for each compiled host + /// import. Binding later uses the full identity, including resource keys, + /// return schema and catalog fingerprint. + pub fn with_host_import_schemas( + mut self, + schemas: Vec, + ) -> Result { + self.program = self.program.with_host_import_schemas(schemas)?; + Ok(self) + } + + /// Selects and attaches catalog schemas for imports that have one + /// unambiguous overload. An import with multiple candidates is rejected + /// until the caller supplies [`Self::with_host_import_schemas`] explicitly, + /// unless the compiler recorded concrete argument schemas that select one. + pub fn with_host_catalog(mut self, catalog: &HostApiCatalog) -> Result { + let mut schemas = Vec::with_capacity(self.program.imports.len()); + for (index, import) in self.program.imports.iter().enumerate() { + let candidates: Vec<_> = catalog + .functions_named(&import.name) + .into_iter() + .filter(|function| function.params.len() == import.arity as usize) + .collect(); + if candidates.is_empty() { + return Err(format!( + "catalog has no overload for host import `{}` with arity {}", + import.name, import.arity + )); + } + let selected = if candidates.len() == 1 { + candidates[0] + } else { + let declaration = self.functions.get(index); + let matching: Vec<_> = candidates + .into_iter() + .filter(|candidate| { + declaration.is_some_and(|declaration| { + declaration.arg_schemas.len() == candidate.params.len() + && declaration + .arg_schemas + .iter() + .zip(candidate.params.iter()) + .all(|(actual, expected)| { + actual.as_ref().is_none_or(|actual| { + compiler_type_matches_host(actual, &expected.ty) + }) + }) + }) + }) + .collect(); + if matching.len() != 1 { + return Err(format!( + "catalog import `{}` with arity {} is ambiguous; attach its full schema", + import.name, import.arity + )); + } + matching[0] + }; + schemas.push(HostImportSchema::from_function(catalog, selected)); + } + self.program = self.program.with_host_import_schemas(schemas)?; + Ok(self) + } + #[cfg(feature = "runtime")] pub fn into_vm(self) -> Vm { Vm::new(self.program) diff --git a/src/compiler/typing/context.rs b/src/compiler/typing/context.rs index efdfb045..c2a6388c 100644 --- a/src/compiler/typing/context.rs +++ b/src/compiler/typing/context.rs @@ -1987,6 +1987,19 @@ impl<'a> TypeContext<'a> { } return Ok(()); } + #[cfg(feature = "runtime")] + if signature.runtime_builtin && signature.name == crate::builtins::runtime::STREAM_EMIT_NAME + { + return validate_host_signature( + &signature.name, + &signature.params, + args, + state, + self, + line_context, + source_name, + ); + } if self.is_strict() && signature .params diff --git a/src/compiler/typing/helpers.rs b/src/compiler/typing/helpers.rs index 28e76a8e..4c6d8a31 100644 --- a/src/compiler/typing/helpers.rs +++ b/src/compiler/typing/helpers.rs @@ -1234,6 +1234,7 @@ pub(super) fn known_host_signature(name: &str) -> Option return Some(HostCallableSignature { name: callable.name.to_string(), params: callable.signature.params.to_vec(), + runtime_builtin: true, }); } @@ -1253,6 +1254,7 @@ pub(super) fn known_host_signature(name: &str) -> Option optional: false, }) .collect(), + runtime_builtin: false, }) } diff --git a/src/compiler/typing/state.rs b/src/compiler/typing/state.rs index 5020bb27..f47071de 100644 --- a/src/compiler/typing/state.rs +++ b/src/compiler/typing/state.rs @@ -435,4 +435,9 @@ pub(crate) struct TypeInferenceResult { pub(crate) struct HostCallableSignature { pub(crate) name: String, pub(crate) params: Vec, + /// True when this signature comes from the authoritative runtime builtin + /// catalog. Same-name functions from another host catalog do not inherit + /// runtime-builtin typing exemptions. + #[cfg_attr(not(feature = "runtime"), allow(dead_code))] + pub(crate) runtime_builtin: bool, } diff --git a/src/compiler/typing/validate.rs b/src/compiler/typing/validate.rs index 6a2b61fe..cd5a9ada 100644 --- a/src/compiler/typing/validate.rs +++ b/src/compiler/typing/validate.rs @@ -424,6 +424,9 @@ fn param_accepts_bound_type(expected: CallableParamType, actual: BoundType, stri } CallableParamType::Map => matches!(actual, BoundType::Map | BoundType::MapOf(_)), CallableParamType::Number => is_numeric_bound_type(actual), + // Resource handles are represented by guest integers at the bytecode + // boundary; the host wrapper performs the typed table lookup. + CallableParamType::Resource => actual == BoundType::Int, } } diff --git a/src/host_api.rs b/src/host_api.rs new file mode 100644 index 00000000..e5d049cf --- /dev/null +++ b/src/host_api.rs @@ -0,0 +1,2014 @@ +//! Shared, host-agnostic semantic model of the host API. +//! +//! This module defines an ordinary, owned, serializable-friendly description of +//! the functions and resource types a host exposes to scripts. It is deliberately +//! independent of the compiler's inference types, the VM's runtime +//! [`crate::vm`] resource table, the wire format ([`crate::vmbc`]) and the +//! generated builtin catalog ([`crate::builtins`]), so all of those can be +//! consumed without introducing a reverse dependency. +//! +//! ## Design invariants +//! +//! * **Host-agnostic.** The catalog carries only semantic signatures: scalar, +//! collection, callable and unknown schemas plus typed resource references. +//! It does not talk about handles, bytecode or VM state. +//! * **Owned and serializable-friendly.** Every type owns its data (`String` / +//! `Vec`) and derives or implements [`serde::Serialize`] / +//! [`serde::Deserialize`]. No lifetimes, no `&'static` slices, no +//! [`std::any::TypeId`]. +//! * **Validated at every boundary.** `ResourceTypeKey` and `HostApiCatalog` +//! implement *validating* deserialization, so malformed keys, duplicate +//! signatures, undeclared resource references and invalid passing modes +//! cannot enter through serde — the same rules the builder enforces. +//! * **Explicit resource ownership.** A parameter whose type **contains any +//! resource**, directly or recursively (`Optional`, `Array`, `Map`, +//! `Callable`), must use an explicit borrow/ownership passing mode; `Value` +//! is forbidden. A parameter whose type contains **no** resource must use +//! `Value`; a borrow/ownership mode is forbidden. +//! * **Overloading.** Host functions may legally share a name with distinct +//! argument signatures (standard builtins such as `len` dispatch for string, +//! array, bytes and map). Overloads must differ in their **argument type / +//! passing-mode sequence**: two functions sharing a name and an identical +//! argument type + passing sequence are ambiguous — parameter names and the +//! return type do not disambiguate call sites — so they are rejected even +//! when those fields differ. +//! * **Deterministic fingerprint.** [`HostApiCatalog::fingerprint`] produces a +//! stable digest over *semantic* fields only, prefixed by a domain magic and +//! a format version. Functions are sorted by their full canonical signature +//! bytes, so overloaded registration order is irrelevant. Documentation is +//! excluded. +//! +//! ## Fingerprint security note +//! +//! The 64-bit FNV-1a fingerprint is **not** a cryptographic digest. It is an +//! equality / change-detection fingerprint only: it is deterministic and +//! collision-resistant *enough* for detecting when two catalogs differ, but it +//! must **never** be used for authentication, integrity, or any context where +//! an attacker can influence catalog bytes. Treat `HostApiFingerprint` as a +//! convenience equality key, not a MAC. + +use std::fmt; + +use serde::Deserialize; + +/// Max byte length of a validated [`ResourceTypeKey`] name. +const MAX_RESOURCE_KEY_LEN: usize = 128; + +/// Max byte length of a validated host function name. +const MAX_FUNCTION_NAME_LEN: usize = 128; + +/// 8-byte domain magic prepended to every fingerprint so digest bytes in one +/// domain (host API catalogs) cannot be confused with unrelated FNV digests +/// produced by other tooling. +const FINGERPRINT_DOMAIN_MAGIC: &[u8; 8] = b"rss-hapi"; + +/// The fingerprint wire/format version. Bump whenever the canonical byte +/// encoding or semantic interpretation changes so old and new digests are +/// never compared across versions. +const FINGERPRINT_FORMAT_VERSION: u8 = 1; + +/// Error returned when a [`ResourceTypeKey`] cannot be constructed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResourceTypeKeyError { + Empty, + TooLong(usize), + InvalidChar { index: usize, ch: char }, + InvalidDotPlacement { index: usize }, +} + +impl fmt::Display for ResourceTypeKeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "resource type key must not be empty"), + Self::TooLong(len) => write!( + f, + "resource type key is {len} bytes; the maximum is {MAX_RESOURCE_KEY_LEN}" + ), + Self::InvalidChar { index, ch } => write!( + f, + "resource type key contains invalid character {ch:?} at byte offset {index}" + ), + Self::InvalidDotPlacement { index } => write!( + f, + "resource type key contains an empty namespace segment at byte offset {index}" + ), + } + } +} + +impl std::error::Error for ResourceTypeKeyError {} + +/// A validated, stable identifier for a host resource type. +/// +/// The key is an ordinary lowercase dot-namespaced name such as `io.file` or +/// `sqlite.connection`. Each segment is a non-empty run of lowercase ASCII +/// letters (`a`-`z`), digits (`0`-`9`), `_` or `-`; no segment-leading-letter +/// requirement exists, so a lone-segment key such as `file` or `0host` is +/// legal. A single-segment key (e.g. `file`) is allowed and simply carries no +/// namespace. `.` is reserved purely as the separator between non-empty +/// segments, so a key may not start or end with a dot and may not contain an +/// empty segment. +/// +/// Validation rejects empty, over-long, non-ASCII and malformed-namespace +/// names so the value can serve as a stable map key, a fingerprint input and +/// a serialized identifier without further laundering. +/// +/// This deliberately replaces any reliance on [`std::any::TypeId`]: resource +/// identity is a value, not a type reflection. +/// +/// Deserialization is validating: a serialized key that fails [`Self::new`] +/// validation is rejected at the serde boundary. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)] +pub struct ResourceTypeKey(String); + +impl ResourceTypeKey { + /// Validates and builds a resource type key. + pub fn new(name: impl Into) -> Result { + let name = name.into(); + validate_resource_key(&name)?; + Ok(Self(name)) + } + + /// The key text, e.g. `io.file`. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ResourceTypeKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for ResourceTypeKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let text = String::deserialize(deserializer)?; + Self::new(text).map_err(serde::de::Error::custom) + } +} + +fn validate_resource_key(name: &str) -> Result<(), ResourceTypeKeyError> { + if name.is_empty() { + return Err(ResourceTypeKeyError::Empty); + } + if name.len() > MAX_RESOURCE_KEY_LEN { + return Err(ResourceTypeKeyError::TooLong(name.len())); + } + // Allowed: ASCII lowercase a-z, 0-9, '_' and '-', with '.' used purely as a + // namespace separator between non-empty segments. + for (index, b) in name.bytes().enumerate() { + let valid = b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.'); + if !valid { + return Err(ResourceTypeKeyError::InvalidChar { + index, + ch: name[index..].chars().next().unwrap_or('\u{fffd}'), + }); + } + } + // Report the exact byte offset of each empty segment: a `.` that directly + // follows another `.` (or the leading dot) opens an empty segment at that + // dot, and a trailing `.` leaves an empty segment at the end of the name. + let mut segment_start = 0usize; + for (index, b) in name.bytes().enumerate() { + if b == b'.' { + if index == segment_start { + return Err(ResourceTypeKeyError::InvalidDotPlacement { index }); + } + segment_start = index + 1; + } + } + if segment_start == name.len() { + return Err(ResourceTypeKeyError::InvalidDotPlacement { + index: segment_start, + }); + } + Ok(()) +} + +/// How a host function receives a parameter. +/// +/// When a parameter's type [`contains`][HostTypeSchema::contains_resource] a +/// resource, **`Value` is forbidden** and the caller must chose one of +/// `Borrow`, `BorrowMut` or `TakeOwned`. When it contains no resource, `Value` +/// is required and a borrow/ownership mode is forbidden. +/// +/// Ownership modes compose with a parameter's *aggregate* resource content: +/// +/// * `Borrow` / `BorrowMut` apply **call-scoped, recursively** to every +/// resource contained anywhere in the type — direct, `Optional`, `Array`, +/// `Map`, or nested inside a `Callable` — so the callee may read (or +/// exclusively mutate) the whole aggregate for the duration of the call +/// without the caller losing the outer value. +/// * `TakeOwned` **transfers ownership of all contained resources** (and of +/// the value itself) to the callee; the caller no longer holds them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum HostParamPassing { + /// The parameter is a plain value with no contained resource; the callee + /// may copy or drop it freely. + Value, + /// An immutable borrow of the argument value; borrows every contained + /// resource call-scoped and recursively. + Borrow, + /// An exclusive mutable borrow of the argument value; mutably borrows + /// every contained resource call-scoped and recursively. + BorrowMut, + /// Ownership of the argument value and all contained owned resources is + /// transferred to the callee. + TakeOwned, +} + +impl HostParamPassing { + /// Whether the mode borrows, mutates or transfers rather than copying. + pub fn is_reference_mode(self) -> bool { + !matches!(self, Self::Value) + } +} + +/// Semantic schema of a single host value type. +/// +/// Covers the same scalar / collection / callable / unknown surface used by +/// the compiler's inference pass, and adds an explicit [`Self::Resource`] +/// variant that references a declared [`ResourceTypeKey`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum HostTypeSchema { + Unknown, + Null, + Int, + Float, + Number, + Bool, + String, + Bytes, + Array(Box), + Map(Box), + Optional(Box), + Callable { + params: Vec, + result: Box, + }, + /// A host resource identified by a declared [`ResourceTypeKey`]. + Resource(ResourceTypeKey), +} + +impl HostTypeSchema { + /// Returns the resource key when this schema (directly, or wrapped in a + /// single optional layer) denotes a host resource. This is a shallow + /// helper; use [`Self::contains_resource`] for the full recursive test. + pub fn resource_key(&self) -> Option<&ResourceTypeKey> { + match self { + Self::Resource(key) => Some(key), + Self::Optional(inner) => inner.resource_key(), + _ => None, + } + } + + /// Whether this schema references at least one resource, anywhere in the + /// tree (direct, `Optional`, `Array`, `Map` value, or inside a `Callable` + /// parameter/result). + pub fn contains_resource(&self) -> bool { + match self { + Self::Resource(_) => true, + Self::Array(inner) | Self::Map(inner) | Self::Optional(inner) => { + inner.contains_resource() + } + Self::Callable { params, result } => { + params.iter().any(|param| param.contains_resource()) || result.contains_resource() + } + Self::Unknown + | Self::Null + | Self::Int + | Self::Float + | Self::Number + | Self::Bool + | Self::String + | Self::Bytes => false, + } + } + + /// Collects every resource key referenced anywhere in this schema tree. + pub fn collect_resource_keys<'a>(&'a self, out: &mut Vec<&'a ResourceTypeKey>) { + match self { + Self::Resource(key) => out.push(key), + Self::Array(inner) | Self::Map(inner) | Self::Optional(inner) => { + inner.collect_resource_keys(out); + } + Self::Callable { params, result } => { + for param in params { + param.collect_resource_keys(out); + } + result.collect_resource_keys(out); + } + Self::Unknown + | Self::Null + | Self::Int + | Self::Float + | Self::Number + | Self::Bool + | Self::String + | Self::Bytes => {} + } + } +} + +impl fmt::Display for HostTypeSchema { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unknown => write!(f, "unknown"), + Self::Null => write!(f, "null"), + Self::Int => write!(f, "int"), + Self::Float => write!(f, "float"), + Self::Number => write!(f, "number"), + Self::Bool => write!(f, "bool"), + Self::String => write!(f, "string"), + Self::Bytes => write!(f, "bytes"), + Self::Array(inner) => write!(f, "array<{inner}>"), + Self::Map(inner) => write!(f, "map<{inner}>"), + Self::Optional(inner) => write!(f, "optional<{inner}>"), + Self::Callable { params, result } => { + write!(f, "fn(")?; + for (index, param) in params.iter().enumerate() { + if index > 0 { + write!(f, ", ")?; + } + write!(f, "{param}")?; + } + write!(f, ") -> {result}") + } + Self::Resource(key) => write!(f, "resource<{key}>"), + } + } +} + +/// Semantic description of one declared host resource type. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ResourceTypeSchema { + /// The stable, validated resource type key. + pub key: ResourceTypeKey, + /// Human-readable documentation; excluded from the fingerprint. + pub description: String, +} + +impl ResourceTypeSchema { + pub fn new(key: ResourceTypeKey, description: impl Into) -> Self { + Self { + key, + description: description.into(), + } + } +} + +/// Semantic description of one host function parameter. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct HostParamSchema { + /// Parameter name, unique within its function. + pub name: String, + pub ty: HostTypeSchema, + pub passing: HostParamPassing, +} + +impl HostParamSchema { + /// Builds a `Value`-passing parameter. Use this only when `ty` contains no + /// resource; a containing resource requires [`Self::with_passing`] with an + /// explicit borrow/ownership mode. + pub fn value(name: impl Into, ty: HostTypeSchema) -> Self { + Self { + name: name.into(), + ty, + passing: HostParamPassing::Value, + } + } + + pub fn with_passing( + name: impl Into, + ty: HostTypeSchema, + passing: HostParamPassing, + ) -> Self { + Self { + name: name.into(), + ty, + passing, + } + } +} + +/// Semantic description of one host function's signature. +/// +/// Only semantic fields (name, parameters, passing modes, return type) feed +/// the catalog fingerprint; `description` is documentation and is excluded. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HostFunctionSchema { + pub name: String, + pub params: Vec, + pub return_type: HostTypeSchema, + /// Human-readable documentation, excluded from the fingerprint. + pub description: String, +} + +impl HostFunctionSchema { + pub fn new(name: impl Into, params: Vec) -> Self { + Self { + name: name.into(), + params, + return_type: HostTypeSchema::Unknown, + description: String::new(), + } + } + + pub fn with_return( + name: impl Into, + params: Vec, + return_type: HostTypeSchema, + ) -> Self { + Self { + name: name.into(), + params, + return_type, + description: String::new(), + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + /// Canonical semantic bytes for this function: name, then the parameter + /// list (each parameter’s name, type and passing mode), then the return + /// type. This is the full semantic encoding used by the catalog + /// fingerprint, so any semantic change (including a parameter-label or + /// return-type change) alters the digest. It is **not** used for overload + /// identity — see [`Self::overload_identity_bytes`]. + fn semantic_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + push_len_str(&mut bytes, &self.name); + push_len(&mut bytes, self.params.len()); + for param in &self.params { + push_len_str(&mut bytes, ¶m.name); + push_type(&mut bytes, ¶m.ty); + push_tag(&mut bytes, passing_tag(param.passing)); + } + push_type(&mut bytes, &self.return_type); + bytes + } + + /// Canonical overload-identity bytes: the function name plus the ordered + /// parameter type schemas and passing modes only. Parameter names, the + /// return schema and documentation are deliberately excluded, so two + /// functions have the same identity precisely when their name and argument + /// type/passing sequence match. Because argument shape is what dispatch + /// and call sites resolve on, that identity being shared makes the + /// overload set ambiguous regardless of labels or return type. + /// + /// This key feeds overload duplicate detection only — never the catalog + /// fingerprint, which keeps using [`Self::semantic_bytes`]. + fn overload_identity_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + push_len_str(&mut bytes, &self.name); + push_len(&mut bytes, self.params.len()); + for param in &self.params { + push_type(&mut bytes, ¶m.ty); + push_tag(&mut bytes, passing_tag(param.passing)); + } + bytes + } +} + +/// A parameter schema retained in a compiled host import identity. +/// +/// The field names intentionally mirror the public catalog schema while +/// keeping the import representation independent from the catalog's prose +/// documentation. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct HostImportParam { + pub name: String, + pub schema: HostTypeSchema, + pub passing: HostParamPassing, +} + +/// The complete schema identity selected for one host import. +/// +/// Runtime binding uses every field here. In particular, parameter resource +/// keys, the return schema, the function name and the catalog fingerprint are +/// retained; arity is only a cheap preliminary check and never an identity. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct HostImportSchema { + pub name: String, + pub params: Vec, + pub return_type: HostTypeSchema, + pub fingerprint: HostApiFingerprint, +} + +impl HostImportSchema { + pub fn from_function(catalog: &HostApiCatalog, function: &HostFunctionSchema) -> Self { + Self { + name: function.name.clone(), + params: function + .params + .iter() + .map(|param| HostImportParam { + name: param.name.clone(), + schema: param.ty.clone(), + passing: param.passing, + }) + .collect(), + return_type: function.return_type.clone(), + fingerprint: catalog.fingerprint(), + } + } + + pub fn arity(&self) -> usize { + self.params.len() + } +} + +/// Why a host function name is invalid. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FunctionNameError { + Empty, + TooLong(usize), + InvalidChar { index: usize, ch: char }, + EmptySegment { index: usize }, +} + +impl fmt::Display for FunctionNameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "host function name must not be empty"), + Self::TooLong(len) => write!( + f, + "host function name is {len} bytes; the maximum is {MAX_FUNCTION_NAME_LEN}" + ), + Self::InvalidChar { index, ch } => write!( + f, + "host function name contains invalid control/whitespace/symbol character \ + {ch:?} at byte offset {index}" + ), + Self::EmptySegment { index } => write!( + f, + "host function name contains an empty `::` path segment at byte offset {index}" + ), + } + } +} + +impl std::error::Error for FunctionNameError {} + +/// Validate a host function name against the grammar used by the standard +/// catalog, e.g. `len`, `__bind_callable`, `bytes::from_utf8`, `io::open`, +/// `jit::set_hot_loop_threshold`. +/// +/// Grammar: one or more path segments joined by the exact `::` separator, each +/// segment being a non-empty ASCII identifier (`[A-Za-z_][A-Za-z0-9_]*`). +/// Named functions must not start or end with `::`, must not contain empty +/// segments (`a::b`, `::`, `a::::b` are rejected), must not contain a lone +/// `:` and must not contain any control/whitespace/symbol outside the segment +/// alphabet. +fn validate_function_name(name: &str) -> Result<(), FunctionNameError> { + if name.is_empty() { + return Err(FunctionNameError::Empty); + } + if name.len() > MAX_FUNCTION_NAME_LEN { + return Err(FunctionNameError::TooLong(name.len())); + } + // Iterate raw bytes; allowed characters are ASCII (alphanumeric, `_`, + // `:`). Any control, whitespace, symbol (`.`, `-`, `@`, …) or non-ASCII + // byte is rejected here; the `::` separator, empty segments and any lone + // `:` are handled by the segment pass below. + for (index, b) in name.bytes().enumerate() { + if !(b.is_ascii_alphanumeric() || b == b'_' || b == b':') { + return Err(FunctionNameError::InvalidChar { + index, + ch: name[index..].chars().next().unwrap_or('\u{fffd}'), + }); + } + } + // Walk the `::`-separated segments tracking each segment's exact byte + // offset, so an empty segment is reported at the offset of the separator + // that opens it rather than at the first separator found in the name. + let mut cursor = 0usize; + for segment in name.split("::") { + if segment.is_empty() { + // `cursor` is the byte offset at which this empty segment begins: + // the start of a `::` separator, or the end of the name when the + // name ends in `::`. + return Err(FunctionNameError::EmptySegment { index: cursor }); + } + let mut chars = segment.chars(); + let first = chars.next().expect("segment is non-empty"); + let valid_start = first.is_ascii_alphabetic() || first == '_'; + if !valid_start { + return Err(FunctionNameError::InvalidChar { + index: cursor, + ch: first, + }); + } + for (offset, c) in segment.char_indices() { + if !(c.is_ascii_alphanumeric() || c == '_') { + return Err(FunctionNameError::InvalidChar { + index: cursor + offset, + ch: c, + }); + } + } + cursor += segment.len() + 2; // skip this segment and the `::` separator + } + Ok(()) +} + +/// Errors produced while building a [`HostApiCatalog`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostApiCatalogError { + DuplicateResourceKey(ResourceTypeKey), + /// Two registered functions share a name and an identical ordered argument + /// type/passing sequence, making the overload set ambiguous. Parameter + /// names, return type and documentation do not disambiguate call sites. + DuplicateFunctionSignature { + name: String, + }, + InvalidFunctionName { + name: String, + reason: FunctionNameError, + }, + DuplicateParameterName { + function: String, + parameter: String, + }, + UnknownResourceReference { + function: String, + key: ResourceTypeKey, + }, + /// A borrow/ownership passing mode was used on a non-resource parameter. + NonResourcePassingMode { + function: String, + parameter: String, + passing: HostParamPassing, + }, + /// A resource-containing parameter was declared with `Value`; an explicit + /// `Borrow`/`BorrowMut`/`TakeOwned` is required. + ResourceValuePassing { + function: String, + parameter: String, + }, +} + +impl fmt::Display for HostApiCatalogError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateResourceKey(key) => write!(f, "duplicate resource type key `{key}`"), + Self::DuplicateFunctionSignature { name } => write!( + f, + "duplicate host function overload `{name}`: identical name and identical \ + argument type/passing sequence (parameter names and return type cannot \ + disambiguate overloads)" + ), + Self::InvalidFunctionName { name, reason } => { + write!(f, "invalid host function name `{name}`: {reason}") + } + Self::DuplicateParameterName { + function, + parameter, + } => write!( + f, + "host function `{function}` declares duplicate parameter name `{parameter}`" + ), + Self::UnknownResourceReference { function, key } => write!( + f, + "host function `{function}` references undeclared resource type `{key}`" + ), + Self::NonResourcePassingMode { + function, + parameter, + passing, + } => write!( + f, + "host function `{function}` uses passing mode {passing:?} on non-resource \ + parameter `{parameter}`; value types must use `Value`", + ), + Self::ResourceValuePassing { + function, + parameter, + } => write!( + f, + "host function `{function}` passes resource-containing parameter `{parameter}` \ + by `Value`; an explicit Borrow/BorrowMut/TakeOwned is required", + ), + } + } +} + +impl std::error::Error for HostApiCatalogError {} + +/// An immutable, validated catalog of the host API surface. +/// +/// Construction is done via the builder ([`HostApiCatalog::builder`]) or via +/// serde; both routes run the same validation, so a catalog is only exposed +/// once all cross-references, passing-mode and name/overload invariants hold. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct HostApiCatalog { + resources: Vec, + functions: Vec, +} + +/// A deterministic 64-bit fingerprint of a [`HostApiCatalog`]. +/// +/// Computed by FNV-1a over a canonical encoding of the semantic fields only, +/// prefixed by a domain magic and a format version. This is an equality / +/// change-detection digest only — **never** an authentication or integrity +/// value. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct HostApiFingerprint(u64); + +impl HostApiFingerprint { + #[allow(dead_code)] + pub(crate) const fn from_wire(value: u64) -> Self { + Self(value) + } + + pub const fn as_u64(self) -> u64 { + self.0 + } +} + +impl serde::Serialize for HostApiFingerprint { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_u64(self.0) + } +} + +impl<'de> serde::Deserialize<'de> for HostApiFingerprint { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(HostApiFingerprint(u64::deserialize(deserializer)?)) + } +} + +impl fmt::Display for HostApiFingerprint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:016x}", self.0) + } +} + +/// Mirror of [`HostApiCatalog`]’s serialized shape so `Deserialize` can parse +/// it and then re-validate, keeping serde as safe as the builder. +#[derive(serde::Deserialize)] +struct HostApiCatalogRepr { + resources: Vec, + functions: Vec, +} + +impl<'de> Deserialize<'de> for HostApiCatalog { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let repr = HostApiCatalogRepr::deserialize(deserializer)?; + let builder = HostApiBuilder { + resources: repr.resources, + functions: repr.functions, + }; + builder.build().map_err(serde::de::Error::custom) + } +} + +/// Stage-one, mutable builder for a [`HostApiCatalog`]. +/// +/// Cross-function invariants (referenced resource keys being declared, +/// reference/ownership passing modes, overload signatures, name grammar, +/// duplicate parameter names) are enforced in [`HostApiBuilder::build`], which +/// is what makes construction order independent. +#[derive(Clone, Debug, Default)] +pub struct HostApiBuilder { + resources: Vec, + functions: Vec, +} + +impl Default for HostApiCatalog { + fn default() -> Self { + Self::builder() + .build() + .expect("empty catalog is always valid") + } +} + +impl HostApiCatalog { + /// Starts an empty, validated-construction catalog builder. + pub fn builder() -> HostApiBuilder { + HostApiBuilder::default() + } + + /// Looks up a host function by exact name, returning it **only when it is + /// unambiguous** (exactly one registered function matches). If none match, + /// or the name is legally overloaded, this returns `None` — use + /// [`Self::functions_named`] to resolve overloads. + pub fn function(&self, name: &str) -> Option<&HostFunctionSchema> { + match self.functions_named(name)[..] { + [single] => Some(single), + _ => None, + } + } + + /// All host functions registered under the given name, preserving + /// registration order. An empty slice means the name is not declared; a + /// non-empty slice of length > 1 means the name is overloaded. + pub fn functions_named(&self, name: &str) -> Vec<&HostFunctionSchema> { + self.functions + .iter() + .filter(|function| function.name == name) + .collect() + } + + /// Looks up a declared resource type by key text. + pub fn resource(&self, key: &str) -> Option<&ResourceTypeSchema> { + self.resources + .iter() + .find(|resource| resource.key.as_str() == key) + } + + /// Whether the catalog declares the given resource type key. + pub fn has_resource(&self, key: &ResourceTypeKey) -> bool { + self.resources.iter().any(|resource| &resource.key == key) + } + + /// All declared resource types (in registration order). + pub fn resources(&self) -> &[ResourceTypeSchema] { + &self.resources + } + + /// All host functions (in registration order). + pub fn functions(&self) -> &[HostFunctionSchema] { + &self.functions + } + + /// Canonical semantic bytes for the whole catalog: `FINGERPRINT_DOMAIN_MAGIC` + /// ++ `FINGERPRINT_FORMAT_VERSION` ++ resources (sorted by key) ++ + /// functions (sorted by full semantic signature bytes). + fn canonical_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + + bytes.extend_from_slice(FINGERPRINT_DOMAIN_MAGIC); + bytes.push(FINGERPRINT_FORMAT_VERSION); + + // Resources sorted by key text. + let mut resources: Vec<&ResourceTypeSchema> = self.resources.iter().collect(); + resources.sort_by(|a, b| a.key.cmp(&b.key)); + push_tag(&mut bytes, b'R'); + push_len(&mut bytes, resources.len()); + for resource in &resources { + push_len_str(&mut bytes, resource.key.as_str()); + } + + // Functions sorted by their full canonical semantic signature bytes so + // overloaded registration order is irrelevant (exact duplicates are + // already rejected at build time). + let mut functions: Vec<&HostFunctionSchema> = self.functions.iter().collect(); + functions.sort_by_key(|a| a.semantic_bytes()); + push_tag(&mut bytes, b'F'); + push_len(&mut bytes, functions.len()); + for function in &functions { + bytes.extend(function.semantic_bytes()); + } + + bytes + } + + /// Deterministic, order-independent fingerprint of the semantic contents. + /// + /// The fingerprint covers resource keys and every function’s name, + /// parameter (name, type, passing mode) and return type. It excludes + /// documentation and registration order. See the module doc for the + /// security caveat: this 64-bit FNV digest is equality / change-detection + /// only, never authentication. + pub fn fingerprint(&self) -> HostApiFingerprint { + HostApiFingerprint(fnv1a(&self.canonical_bytes())) + } +} + +/// Validate the caller-supplied resource/function collections. Shared by the +/// builder and the serde path so both reject the same malformed inputs. +fn validate_surface( + resources: &[ResourceTypeSchema], + functions: &[HostFunctionSchema], +) -> Result<(), HostApiCatalogError> { + // Duplicate resource keys. + for (i, resource) in resources.iter().enumerate() { + if resources[..i].iter().any(|prior| prior.key == resource.key) { + return Err(HostApiCatalogError::DuplicateResourceKey( + resource.key.clone(), + )); + } + } + + // Per-function invariants. + for function in functions { + // Valid function name. + if let Err(reason) = validate_function_name(&function.name) { + return Err(HostApiCatalogError::InvalidFunctionName { + name: function.name.clone(), + reason, + }); + } + + // Unique parameter names. + for (i, param) in function.params.iter().enumerate() { + if function.params[..i] + .iter() + .any(|prior| prior.name == param.name) + { + return Err(HostApiCatalogError::DuplicateParameterName { + function: function.name.clone(), + parameter: param.name.clone(), + }); + } + } + + // Passing-mode and resource-reference invariants. + for param in &function.params { + let contains_resource = param.ty.contains_resource(); + if contains_resource { + // A resource-containing parameter must use an explicit mode. + if param.passing == HostParamPassing::Value { + return Err(HostApiCatalogError::ResourceValuePassing { + function: function.name.clone(), + parameter: param.name.clone(), + }); + } + } else if param.passing.is_reference_mode() { + // A non-resource parameter must use `Value`. + return Err(HostApiCatalogError::NonResourcePassingMode { + function: function.name.clone(), + parameter: param.name.clone(), + passing: param.passing, + }); + } + + // Every referenced resource key must be declared. + let mut keys = Vec::new(); + param.ty.collect_resource_keys(&mut keys); + for key in keys { + if !resources.iter().any(|resource| &resource.key == key) { + return Err(HostApiCatalogError::UnknownResourceReference { + function: function.name.clone(), + key: key.clone(), + }); + } + } + } + + // Return references must be declared too. + let mut keys = Vec::new(); + function.return_type.collect_resource_keys(&mut keys); + for key in keys { + if !resources.iter().any(|resource| &resource.key == key) { + return Err(HostApiCatalogError::UnknownResourceReference { + function: function.name.clone(), + key: key.clone(), + }); + } + } + } + + // Reject ambiguous overloads: two functions sharing a name and an identical + // ordered argument type/passing sequence. Parameter names and the return + // type do not disambiguate call sites, so same-name overloads that differ + // only in labels or return schema are rejected. Legal overloads (same name, + // distinct argument schema) are allowed. + for (i, function) in functions.iter().enumerate() { + let identity = function.overload_identity_bytes(); + for prior in &functions[..i] { + if prior.overload_identity_bytes() == identity { + return Err(HostApiCatalogError::DuplicateFunctionSignature { + name: function.name.clone(), + }); + } + } + } + + Ok(()) +} + +impl HostApiBuilder { + /// Starts an empty catalog builder. + pub fn new() -> Self { + Self::default() + } + + /// Registers a resource type. + pub fn resource(&mut self, resource: ResourceTypeSchema) { + self.resources.push(resource); + } + + /// Registers a host function signature. Same-name functions with distinct + /// signatures (overloads) are allowed. + pub fn function(&mut self, function: HostFunctionSchema) { + self.functions.push(function); + } + + /// Returns the number of resource types registered so far. + pub fn resource_count(&self) -> usize { + self.resources.len() + } + + /// Returns the number of functions registered so far. + pub fn function_count(&self) -> usize { + self.functions.len() + } + + /// Validates and freezes the catalog. + pub fn build(self) -> Result { + validate_surface(&self.resources, &self.functions)?; + Ok(HostApiCatalog { + resources: self.resources, + functions: self.functions, + }) + } +} + +fn push_tag(bytes: &mut Vec, tag: u8) { + bytes.push(tag); +} + +fn push_len(bytes: &mut Vec, value: usize) { + // Fixed 8-byte little-endian length so encodings are unambiguous, and any + // structural field write is order-independent in aggregate. + bytes.extend_from_slice(&(value as u64).to_le_bytes()); +} + +fn push_len_str(bytes: &mut Vec, value: &str) { + push_len(bytes, value.len()); + bytes.extend_from_slice(value.as_bytes()); +} + +fn push_type(bytes: &mut Vec, schema: &HostTypeSchema) { + match schema { + HostTypeSchema::Unknown => push_tag(bytes, b'U'), + HostTypeSchema::Null => push_tag(bytes, b'N'), + HostTypeSchema::Int => push_tag(bytes, b'I'), + HostTypeSchema::Float => push_tag(bytes, b'F'), + HostTypeSchema::Number => push_tag(bytes, b'#'), + HostTypeSchema::Bool => push_tag(bytes, b'B'), + HostTypeSchema::String => push_tag(bytes, b'S'), + HostTypeSchema::Bytes => push_tag(bytes, b'Y'), + HostTypeSchema::Array(inner) => { + push_tag(bytes, b'['); + push_type(bytes, inner); + } + HostTypeSchema::Map(inner) => { + push_tag(bytes, b'{'); + push_type(bytes, inner); + } + HostTypeSchema::Optional(inner) => { + push_tag(bytes, b'?'); + push_type(bytes, inner); + } + HostTypeSchema::Callable { params, result } => { + push_tag(bytes, b'c'); + push_len(bytes, params.len()); + for param in params { + push_type(bytes, param); + } + push_type(bytes, result); + } + HostTypeSchema::Resource(key) => { + push_tag(bytes, b'r'); + push_len_str(bytes, key.as_str()); + } + } +} + +fn passing_tag(passing: HostParamPassing) -> u8 { + match passing { + HostParamPassing::Value => b'v', + // Distinct tags so Borrow and BorrowMut are semantically different. + HostParamPassing::Borrow => b'b', + HostParamPassing::BorrowMut => b'm', + HostParamPassing::TakeOwned => b'o', + } +} + +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = FNV_OFFSET_BASIS; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn io_file_key() -> ResourceTypeKey { + ResourceTypeKey::new("io.file").expect("valid key") + } + + fn sqlite_connection_key() -> ResourceTypeKey { + ResourceTypeKey::new("sqlite.connection").expect("valid key") + } + + fn io_file_resource() -> ResourceTypeSchema { + ResourceTypeSchema::new(io_file_key(), "An open file handle") + } + + fn sqlite_connection_resource() -> ResourceTypeSchema { + ResourceTypeSchema::new(sqlite_connection_key(), "An open SQLite connection") + } + + fn fn_io_open(docs: &str) -> HostFunctionSchema { + HostFunctionSchema::with_return( + "io::open", + vec![ + HostParamSchema::value("path", HostTypeSchema::String), + HostParamSchema::value("mode", HostTypeSchema::String), + ], + HostTypeSchema::Resource(io_file_key()), + ) + .with_description(docs) + } + + fn fn_io_read_all(passing: HostParamPassing) -> HostFunctionSchema { + HostFunctionSchema::with_return( + "io::read_all", + vec![HostParamSchema::with_passing( + "handle", + HostTypeSchema::Resource(io_file_key()), + passing, + )], + HostTypeSchema::String, + ) + } + + fn fn_sqlite_open() -> HostFunctionSchema { + HostFunctionSchema::with_return( + "sqlite::open", + vec![HostParamSchema::value("path", HostTypeSchema::String)], + HostTypeSchema::Resource(sqlite_connection_key()), + ) + } + + fn catalog_with_io_and_sqlite() -> HostApiCatalog { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.resource(sqlite_connection_resource()); + builder.function(fn_io_open("docs")); + builder.function(fn_io_read_all(HostParamPassing::Borrow)); + builder.function(fn_sqlite_open()); + builder.build().expect("valid catalog") + } + + // --- ResourceTypeKey validation --- + + #[test] + fn resource_type_key_validation() { + assert!(ResourceTypeKey::new("io.file").is_ok()); + assert!(ResourceTypeKey::new("sqlite.connection").is_ok()); + assert!(ResourceTypeKey::new("a-b_c.0").is_ok()); + assert_eq!(ResourceTypeKey::new(""), Err(ResourceTypeKeyError::Empty)); + assert!(ResourceTypeKey::new("A").is_err()); + assert!(ResourceTypeKey::new("has space").is_err()); + assert!(ResourceTypeKey::new(".leading").is_err()); + assert!(ResourceTypeKey::new("trailing.").is_err()); + assert!(ResourceTypeKey::new("double..dot").is_err()); + assert!(ResourceTypeKey::new("a".repeat(129)).is_err()); + } + + #[test] + fn resource_type_key_deduplicates_by_value() { + assert_eq!( + ResourceTypeKey::new("io.file").unwrap(), + ResourceTypeKey::new("io.file").unwrap() + ); + assert_ne!( + ResourceTypeKey::new("io.file").unwrap(), + ResourceTypeKey::new("io.file2").unwrap() + ); + } + + // --- Catalog construction validation --- + + #[test] + fn duplicate_resource_key_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.resource(ResourceTypeSchema::new(io_file_key(), "duplicate")); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateResourceKey(io_file_key())) + ); + } + + // --- Overloading --- + + #[test] + fn legal_overloads_allowed() { + // Standard builtins legally overload `len` for multiple value shapes. + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value("value", HostTypeSchema::String)], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value( + "value", + HostTypeSchema::Map(Box::new(HostTypeSchema::String)), + )], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value("value", HostTypeSchema::Bytes)], + HostTypeSchema::Int, + )); + let catalog = builder.build().expect("legal overloads must build"); + assert_eq!(catalog.functions_named("len").len(), 3); + // Ambiguous name => `function` returns None, `functions_named` returns all. + assert!(catalog.function("len").is_none()); + } + + #[test] + fn exact_duplicate_overload_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(fn_io_open("one")); + // Identical name, identical params, identical return => exact duplicate. + let duplicate = HostFunctionSchema::with_return( + "io::open", + vec![ + HostParamSchema::value("path", HostTypeSchema::String), + HostParamSchema::value("mode", HostTypeSchema::String), + ], + HostTypeSchema::Resource(io_file_key()), + ); + builder.function(duplicate); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateFunctionSignature { + name: "io::open".to_string() + }) + ); + } + + #[test] + fn same_signature_different_return_rejected() { + // Same name, same argument type/passing sequence, but a differing + // return type: still ambiguous at call sites, so rejected. + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "convert", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "convert", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::String, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateFunctionSignature { + name: "convert".to_string() + }) + ); + } + + #[test] + fn same_signature_different_parameter_labels_rejected() { + // Same name, same argument types+passing, but different parameter + // labels => identical overload identity, so rejected. + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "get", + vec![ + HostParamSchema::value("a", HostTypeSchema::Int), + HostParamSchema::value("b", HostTypeSchema::String), + ], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "get", + vec![ + HostParamSchema::value("x", HostTypeSchema::Int), + HostParamSchema::value("y", HostTypeSchema::String), + ], + HostTypeSchema::Int, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateFunctionSignature { + name: "get".to_string() + }) + ); + } + + #[test] + fn ambiguous_argument_identity_with_resource_same_passing_rejected() { + // Same resource argument and borrowing mode in both overloads, differing + // only in the return resource: argument identity is the same => rejected. + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.resource(sqlite_connection_resource()); + builder.function(HostFunctionSchema::with_return( + "open", + vec![HostParamSchema::with_passing( + "path", + HostTypeSchema::String, + HostParamPassing::Value, + )], + HostTypeSchema::Resource(io_file_key()), + )); + builder.function(HostFunctionSchema::with_return( + "open", + vec![HostParamSchema::with_passing( + "loc", + HostTypeSchema::String, + HostParamPassing::Value, + )], + HostTypeSchema::Resource(sqlite_connection_key()), + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateFunctionSignature { + name: "open".to_string() + }) + ); + } + + #[test] + fn ambiguous_function_lookup_returns_none() { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value("value", HostTypeSchema::String)], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog = builder.build().expect("valid"); + assert!(catalog.function("len").is_none()); + assert_eq!(catalog.functions_named("len").len(), 2); + assert!(catalog.function("absent").is_none()); + assert!(catalog.functions_named("absent").is_empty()); + } + + #[test] + fn unambiguous_function_lookup_returns_it() { + let catalog = catalog_with_io_and_sqlite(); + assert_eq!( + catalog.function("io::open").expect("unique").name, + "io::open" + ); + assert!(catalog.function("io::read_all").is_some()); + } + + // --- Ownership mode enforcement --- + + #[test] + fn non_resource_borrow_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "write", + vec![HostParamSchema::with_passing( + "text", + HostTypeSchema::String, + HostParamPassing::Borrow, + )], + HostTypeSchema::Null, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::NonResourcePassingMode { + function: "write".to_string(), + parameter: "text".to_string(), + passing: HostParamPassing::Borrow, + }) + ); + } + + #[test] + fn non_resource_take_owned_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "consume", + vec![HostParamSchema::with_passing( + "value", + HostTypeSchema::Int, + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Int, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::NonResourcePassingMode { + function: "consume".to_string(), + parameter: "value".to_string(), + passing: HostParamPassing::TakeOwned, + }) + ); + } + + #[test] + fn non_resource_deeply_nested_borrow_rejected() { + // An Array contains no resource, so Borrow is forbidden even + // though `resource_key()` (shallow) would say None too. + let mut builder = HostApiCatalog::builder(); + let array_of_strings = HostTypeSchema::Array(Box::new(HostTypeSchema::String)); + assert!(!array_of_strings.contains_resource()); + builder.function(HostFunctionSchema::with_return( + "join", + vec![HostParamSchema::with_passing( + "parts", + array_of_strings, + HostParamPassing::Borrow, + )], + HostTypeSchema::String, + )); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::NonResourcePassingMode { .. }) + )); + } + + #[test] + fn resource_value_passing_rejected() { + for ty in [ + HostTypeSchema::Resource(io_file_key()), + HostTypeSchema::Optional(Box::new(HostTypeSchema::Resource(io_file_key()))), + HostTypeSchema::Array(Box::new(HostTypeSchema::Resource(io_file_key()))), + HostTypeSchema::Map(Box::new(HostTypeSchema::Resource(io_file_key()))), + HostTypeSchema::Callable { + params: vec![HostTypeSchema::Resource(io_file_key())], + result: Box::new(HostTypeSchema::String), + }, + ] { + assert!(ty.contains_resource(), "schema must carry a resource"); + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "takes_resource", + vec![HostParamSchema::value("value", ty)], + HostTypeSchema::Null, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::ResourceValuePassing { + function: "takes_resource".to_string(), + parameter: "value".to_string(), + }) + ); + } + } + + #[test] + fn resource_in_container_with_explicit_pass_allowed() { + // An Array may be passed with an explicit mode (Borrow), + // which applies call-scoped to the contained resources. + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "close_all", + vec![HostParamSchema::with_passing( + "handles", + HostTypeSchema::Array(Box::new(HostTypeSchema::Resource(io_file_key()))), + HostParamPassing::Borrow, + )], + HostTypeSchema::Null, + )); + builder.function(HostFunctionSchema::with_return( + "reap", + vec![HostParamSchema::with_passing( + "handles", + HostTypeSchema::Map(Box::new(HostTypeSchema::Resource(io_file_key()))), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Null, + )); + builder + .build() + .expect("explicit aggregate passing is valid"); + } + + #[test] + fn undeclared_resource_in_param_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "use_missing", + vec![HostParamSchema::with_passing( + "h", + HostTypeSchema::Resource(ResourceTypeKey::new("missing.file").unwrap()), + HostParamPassing::Borrow, + )], + HostTypeSchema::Null, + )); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::UnknownResourceReference { .. }) + )); + } + + #[test] + fn undeclared_resource_return_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "open", + vec![], + HostTypeSchema::Resource(ResourceTypeKey::new("missing.file").unwrap()), + )); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::UnknownResourceReference { .. }) + )); + } + + #[test] + fn undeclared_resource_inside_container_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "get_files", + vec![], + HostTypeSchema::Map(Box::new(HostTypeSchema::Resource( + ResourceTypeKey::new("db.files").unwrap(), + ))), + )); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::UnknownResourceReference { .. }) + )); + } + + // --- Function name grammar --- + + #[test] + fn function_name_grammar_accepts_standard_names() { + for name in [ + "len", + "__bind_callable", + "bytes::from_utf8", + "io::open", + "io::read_all", + "jit::set_hot_loop_threshold", + "math::atan2", + "bytes::from_array_u8", + "_private", + ] { + assert!( + validate_function_name(name).is_ok(), + "`{name}` must be a valid host function name" + ); + } + } + + #[test] + fn function_name_grammar_rejects_malformed() { + let invalid: &[&str] = &[ + "", + "::leading", + "trailing::", + "double::::colon", + "a:b", // lone single colon, not `::` + "a a", // whitespace + "1abc", // segment starts with digit + "a-b", // hyphen is a symbol + "a.b", // dot is a resource-key separator, not a function separator + "-x", // leading symbol + "a\nb", // control/whitespace + "a\\tb", // tab + "caf\u{e9}", // non-ASCII (é) + "a\"b", // quote symbol + ]; + for name in invalid { + assert!( + validate_function_name(name).is_err(), + "`{name}` should be rejected as a host function name" + ); + } + } + + #[test] + fn function_name_too_long_rejected() { + let too_long = "a".repeat(MAX_FUNCTION_NAME_LEN + 1); + assert_eq!( + validate_function_name(&too_long), + Err(FunctionNameError::TooLong(too_long.len())) + ); + } + + #[test] + fn empty_function_name_rejected() { + assert_eq!(validate_function_name(""), Err(FunctionNameError::Empty)); + } + + #[test] + fn invalid_function_name_rejected_at_build() { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::new("bad name", vec![])); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::InvalidFunctionName { .. }) + )); + } + + // --- Duplicate parameter names --- + + #[test] + fn duplicate_parameter_name_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "dup", + vec![ + HostParamSchema::value("a", HostTypeSchema::Int), + HostParamSchema::value("a", HostTypeSchema::String), + ], + HostTypeSchema::Null, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateParameterName { + function: "dup".to_string(), + parameter: "a".to_string(), + }) + ); + } + + // --- Display --- + + #[test] + fn resource_displays_as_resource_angle_brackets() { + let s = HostTypeSchema::Resource(io_file_key()); + assert_eq!(format!("{s}"), "resource"); + let opt = HostTypeSchema::Optional(Box::new(s)); + assert_eq!(format!("{opt}"), "optional>"); + } + + // --- Fingerprint semantics --- + + #[test] + fn fingerprint_has_domain_magic_and_version() { + let catalog = catalog_with_io_and_sqlite(); + let bytes = catalog.canonical_bytes(); + assert_eq!( + &bytes[..FINGERPRINT_DOMAIN_MAGIC.len()], + FINGERPRINT_DOMAIN_MAGIC + ); + assert_eq!( + bytes[FINGERPRINT_DOMAIN_MAGIC.len()], + FINGERPRINT_FORMAT_VERSION + ); + assert_ne!(catalog.fingerprint().as_u64(), 0); + } + + #[test] + fn fingerprint_version_is_one() { + assert_eq!(FINGERPRINT_FORMAT_VERSION, 1); + } + + #[test] + fn order_independent_fingerprint() { + let mut builder_a = HostApiCatalog::builder(); + builder_a.resource(io_file_resource()); + builder_a.resource(sqlite_connection_resource()); + builder_a.function(fn_io_read_all(HostParamPassing::Borrow)); + builder_a.function(fn_sqlite_open()); + builder_a.function(fn_io_open("docs")); + let catalog_a = builder_a.build().expect("valid"); + + let mut builder_b = HostApiCatalog::builder(); + builder_b.function(fn_io_open("other docs")); + builder_b.resource(sqlite_connection_resource()); + builder_b.function(fn_sqlite_open()); + builder_b.resource(io_file_resource()); + builder_b.function(fn_io_read_all(HostParamPassing::Borrow)); + let catalog_b = builder_b.build().expect("valid"); + + assert_eq!(catalog_a.fingerprint(), catalog_b.fingerprint()); + } + + /// Two catalogs exposing the same overloaded `len` set but registered in + /// different orders must fingerprint identically. + #[test] + fn overload_order_independent_fingerprint() { + let mut builder_a = HostApiCatalog::builder(); + builder_a.function(len_overload(HostTypeSchema::String)); + builder_a.function(len_overload(HostTypeSchema::Array(Box::new( + HostTypeSchema::Int, + )))); + builder_a.function(len_overload(HostTypeSchema::Bytes)); + let a = builder_a.build().expect("valid"); + + let mut builder_b = HostApiCatalog::builder(); + builder_b.function(len_overload(HostTypeSchema::Bytes)); + builder_b.function(len_overload(HostTypeSchema::String)); + builder_b.function(len_overload(HostTypeSchema::Array(Box::new( + HostTypeSchema::Int, + )))); + let b = builder_b.build().expect("valid"); + + assert_eq!(a.fingerprint(), b.fingerprint()); + assert_eq!(a.fingerprint(), a.fingerprint()); + + // Adding a distinct overload changes the fingerprint (semantic change). + let mut builder_c = HostApiCatalog::builder(); + builder_c.function(len_overload(HostTypeSchema::String)); + builder_c.function(len_overload(HostTypeSchema::Array(Box::new( + HostTypeSchema::Int, + )))); + builder_c.function(len_overload(HostTypeSchema::Map(Box::new( + HostTypeSchema::String, + )))); + let c = builder_c.build().expect("valid"); + assert_ne!(a.fingerprint(), c.fingerprint()); + } + + #[test] + fn semantic_change_alters_fingerprint() { + let base = catalog_with_io_and_sqlite(); + + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.resource(sqlite_connection_resource()); + builder.function(HostFunctionSchema::with_return( + "io::open", + vec![ + HostParamSchema::value("path", HostTypeSchema::String), + HostParamSchema::value("mode", HostTypeSchema::String), + ], + HostTypeSchema::String, + )); + builder.function(fn_io_read_all(HostParamPassing::Borrow)); + builder.function(fn_sqlite_open()); + let changed = builder.build().expect("valid"); + + assert_ne!(base.fingerprint(), changed.fingerprint()); + } + + #[test] + fn param_label_change_alters_fingerprint() { + // Overload identity ignores labels, but the fingerprint must still see + // them (semantic_bytes is unchanged and label-full). + let mut a = HostApiCatalog::builder(); + a.function(HostFunctionSchema::with_return( + "f", + vec![HostParamSchema::value("a", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog_a = a.build().expect("valid"); + + let mut b = HostApiCatalog::builder(); + b.function(HostFunctionSchema::with_return( + "f", + vec![HostParamSchema::value("renamed", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog_b = b.build().expect("valid"); + + assert_ne!(catalog_a.fingerprint(), catalog_b.fingerprint()); + } + + #[test] + fn return_type_change_alters_fingerprint() { + // Two catalogs whose only difference is a return type must have + // distinct fingerprints. + let mut a = HostApiCatalog::builder(); + a.function(HostFunctionSchema::with_return( + "convert", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog_a = a.build().expect("valid"); + + let mut b = HostApiCatalog::builder(); + b.function(HostFunctionSchema::with_return( + "convert", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::String, + )); + let catalog_b = b.build().expect("valid"); + + assert_ne!(catalog_a.fingerprint(), catalog_b.fingerprint()); + } + + #[test] + fn passing_mode_change_alters_fingerprint() { + let base = catalog_with_io_and_sqlite(); + + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.resource(sqlite_connection_resource()); + builder.function(fn_io_open("docs")); + builder.function(fn_io_read_all(HostParamPassing::TakeOwned)); + builder.function(fn_sqlite_open()); + let changed = builder.build().expect("valid"); + + assert_ne!(base.fingerprint(), changed.fingerprint()); + assert_ne!( + passing_tag(HostParamPassing::Borrow), + passing_tag(HostParamPassing::BorrowMut) + ); + } + + #[test] + fn docs_change_does_not_alter_fingerprint() { + let mut a = HostApiCatalog::builder(); + a.resource(io_file_resource()); + a.function(fn_io_open("first description")); + a.function(fn_io_read_all(HostParamPassing::Borrow)); + let catalog_a = a.build().expect("valid"); + + let mut b = HostApiCatalog::builder(); + b.resource(ResourceTypeSchema::new( + io_file_key(), + "completely different docs", + )); + b.function(fn_io_open("second description")); + b.function(fn_io_read_all(HostParamPassing::Borrow)); + let catalog_b = b.build().expect("valid"); + + assert_eq!(catalog_a.fingerprint(), catalog_b.fingerprint()); + assert_ne!(catalog_a, catalog_b); + } + + #[test] + fn fingerprint_is_stable() { + let catalog = catalog_with_io_and_sqlite(); + assert_eq!(catalog.fingerprint(), catalog.fingerprint()); + } + + // --- Lookups --- + + #[test] + fn lookup_function_and_resource() { + let catalog = catalog_with_io_and_sqlite(); + + let open = catalog.function("io::open").expect("io::open present"); + assert_eq!(open.params.len(), 2); + assert_eq!(open.return_type, HostTypeSchema::Resource(io_file_key())); + + let read = catalog.function("io::read_all").expect("present"); + assert_eq!(read.params[0].passing, HostParamPassing::Borrow); + + let sqlite = catalog.function("sqlite::open").expect("present"); + assert_eq!( + sqlite.return_type, + HostTypeSchema::Resource(sqlite_connection_key()) + ); + + assert!(catalog.resource("io.file").is_some()); + assert!(catalog.resource("sqlite.connection").is_some()); + assert!(catalog.has_resource(&io_file_key())); + assert!(catalog.has_resource(&sqlite_connection_key())); + assert!(catalog.resource("does.not.exist").is_none()); + assert!(catalog.function("io::nope").is_none()); + } + + // --- Serde / validating deserialization --- + + fn valid_catalog_json() -> serde_json::Value { + json!({ + "resources": [{ "key": "io.file", "description": "file" }], + "functions": [{ + "name": "io::read_all", + "params": [ + { "name": "handle", "ty": { "Resource": "io.file" }, "passing": "Borrow" } + ], + "return_type": "String", + "description": "" + }] + }) + } + + #[test] + fn serde_round_trip_valid_catalog() { + let catalog: HostApiCatalog = + serde_json::from_value(valid_catalog_json()).expect("valid JSON should deserialize"); + assert_eq!(catalog.fingerprint(), catalog.fingerprint()); + assert_eq!(catalog.functions_named("io::read_all").len(), 1); + } + + #[test] + fn serde_rejects_malformed_resource_key() { + // A bare malformed key must fail ResourceTypeKey's own Deserialize. + assert!(serde_json::from_str::("\"bad key\"").is_err()); + assert!(serde_json::from_str::("\"a..b\"").is_err()); + + // And a malformed key hiding inside a catalog's resources must fail. + let mut v = valid_catalog_json(); + v["resources"][0]["key"] = json!("has space"); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn serde_rejects_duplicate_overload() { + // Same name, identical params, identical return -> duplicate overload. + let mut v = valid_catalog_json(); + let dup = v["functions"][0].clone(); + v["functions"].as_array_mut().unwrap().push(dup); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn serde_rejects_ambiguous_overload_by_arg_identity() { + // The serde path runs the same validate_surface as the builder: two + // functions sharing a name and argument type/passing sequence are + // rejected even when only the return type differs. + let hostile = r#"{ + "resources": [], + "functions": [ + { + "name": "convert", + "params": [ + { "name": "value", "ty": "Int", "passing": "Value" } + ], + "return_type": "Int", + "description": "" + }, + { + "name": "convert", + "params": [ + { "name": "value", "ty": "Int", "passing": "Value" } + ], + "return_type": "String", + "description": "" + } + ] + }"#; + assert!(serde_json::from_str::(hostile).is_err()); + } + + #[test] + fn serde_rejects_undeclared_resource_reference() { + let mut v = valid_catalog_json(); + v["functions"][0]["params"][0]["ty"] = json!({ "Resource": "missing.file" }); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn serde_rejects_invalid_passing_modes() { + // Value on a resource-containing param. + let mut v = valid_catalog_json(); + v["functions"][0]["params"][0]["passing"] = json!("Value"); + assert!(serde_json::from_value::(v).is_err()); + + // A borrow on a non-resource (String) param. + let mut v = valid_catalog_json(); + v["functions"][0]["params"][0]["ty"] = json!("String"); + v["functions"][0]["params"][0]["passing"] = json!("Borrow"); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn serde_rejects_invalid_function_name() { + let mut v = valid_catalog_json(); + v["functions"][0]["name"] = json!("bad name"); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn serde_rejects_duplicate_parameter_name() { + let mut v = valid_catalog_json(); + v["functions"][0]["params"] = json!([ + { "name": "x", "ty": "String", "passing": "Value" }, + { "name": "x", "ty": "Int", "passing": "Value" } + ]); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn fingerprint_serde_round_trip_and_value() { + let fp = HostApiFingerprint(0xdead_beef); + let s = serde_json::to_string(&fp).unwrap(); + assert_eq!(s, "3735928559"); // u64 numeric via transparent + let back: HostApiFingerprint = serde_json::from_str(&s).unwrap(); + assert_eq!(back, fp); + assert_eq!(back.as_u64(), fp.as_u64()); + } + + // --- helpers used by tests above --- + + fn len_overload(ty: HostTypeSchema) -> HostFunctionSchema { + HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value("value", ty)], + HostTypeSchema::Int, + ) + } +} diff --git a/src/lib.rs b/src/lib.rs index 90940354..1f5a2626 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod compiler; pub mod debug_info; #[cfg(feature = "runtime")] pub mod debugger; +pub mod host_api; #[cfg(feature = "runtime")] pub mod jit { pub use crate::vm::jit::{ @@ -22,11 +23,21 @@ pub mod vmbc; pub use assembler::{AsmParseError, Assembler, AssemblerError, BytecodeBuilder, assemble}; #[cfg(feature = "runtime")] -pub use builtins::runtime::HostCallResult; -#[cfg(feature = "runtime")] pub use builtins::runtime::print::{PrintHostFunction, PrintlnHostFunction, format_value}; #[cfg(all(feature = "runtime", feature = "sqlite", not(target_arch = "wasm32")))] pub use builtins::runtime::sqlite::{SqliteHostExt, SqliteLimits, SqlitePolicy}; +#[cfg(feature = "runtime")] +pub(crate) fn install_default_host_functions(registry: &mut vm::HostFunctionRegistry) { + builtins::runtime::register_default_host_functions(registry); +} + +#[cfg(feature = "runtime")] +pub use builtins::runtime::standard_composition; +#[cfg(feature = "runtime")] +pub use builtins::runtime::{ + BorrowVmValue, FromVmValue, HostCallResult, IntoHostCallOutcome, TakeVmValue, arg, borrow_arg, + return_one, take_arg, +}; #[cfg(all(feature = "runtime", not(target_arch = "wasm32")))] pub use builtins::runtime::{IoHostExt, IoPolicy}; pub use builtins::{ @@ -41,6 +52,16 @@ pub use bytecode::{ CaptureBindingMode, ExportedCallable, FunctionRegion, HostImport, OpCode, Program, RootCallableBinding, ScriptFunction, TypeMap, Value, ValueType, }; +pub use host_api::{ + FunctionNameError, HostApiBuilder, HostApiCatalog, HostApiCatalogError, HostApiFingerprint, + HostFunctionSchema, HostParamPassing, HostParamSchema, HostTypeSchema, ResourceTypeKey, + ResourceTypeKeyError, ResourceTypeSchema, +}; +#[cfg(feature = "runtime")] +pub use vm::runtime::{ + EventLimits, EventPayload, RuntimeContext, RuntimeContextConfig, RuntimeError, + RuntimeErrorCode, RuntimeResult, STREAM_EMIT_NAME, +}; pub fn builtin_call_index(name: &str) -> Option { use builtins::BuiltinFunction; @@ -85,13 +106,22 @@ pub use jit::{ #[cfg(feature = "runtime")] pub use vm::diagnostics::render_vm_error; #[cfg(feature = "runtime")] +pub use vm::resource::{Resource, ResourceHandle, ResourceMut, ResourceOwned, ResourceRef}; +#[cfg(feature = "runtime")] pub use vm::{ - AotArtifactError, CallOutcome, CallReturn, DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, - EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, - HostFunctionRegistry, HostOpId, HostStackFunction, IntoScriptValue, QueuedScriptInvocation, - ResourceCloseReason, ScriptArgs, ScriptCallback, ScriptResult, StaticHostArgsFunction, + AotArtifactError, CallOutcome, CallReturn, CapabilityProfile, CapabilityProfileBuilder, + CaptureAsyncHostContext, CatalogRegistrationError, CatalogSchemaSelection, + DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, EpochHandle, FuelCheckpoint, HostArgsFunction, + HostAsyncBridge, HostAsyncOpTerminal, HostBindingPlan, HostContext, HostContextError, + HostContextErrorKind, HostContextResult, HostExtension, HostFunction, HostFunctionRegistry, + HostFuture, HostFutureOutput, HostImportParam, HostImportSchema, HostModule, HostModuleState, + HostOpId, HostStackFunction, IntoScriptValue, Invocation, InvocationError, InvocationItem, + InvocationPoll, QueuedScriptInvocation, RegistrySchemaError, ResourceCloseReason, ScriptArgs, + ScriptCallback, ScriptResult, StandardSurfaceComposition, StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, - VmYieldReason, execution_scope, operation, resource, + VmYieldReason, async_host, catalog_import_schemas, execution_scope, host_context, + host_extension, operation, register_catalog_function, register_catalog_static_function, + resource, validate_catalog_import_schemas, validate_catalog_import_schemas_with_fingerprints, }; #[cfg(feature = "runtime")] pub use vmbc::{ diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index 0703828f..7491d796 100644 --- a/src/vm/aot/artifact.rs +++ b/src/vm/aot/artifact.rs @@ -443,7 +443,23 @@ impl<'a> Cursor<'a> { #[cfg(test)] mod tests { use super::*; - use crate::{BytecodeBuilder, Program, Value, ValueType, VmStatus}; + use crate::host_api::{ + HostApiBuilder, HostFunctionSchema, HostImportSchema, HostParamSchema, HostTypeSchema, + }; + use crate::{ + BytecodeBuilder, CallOutcome, CallReturn, HostFunctionRegistry, Program, Value, ValueType, + VmResult, VmStatus, + }; + + fn aot_overloaded_int(_vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(CallReturn::one(Value::Int(11)))) + } + + fn aot_overloaded_string(_vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(CallReturn::one(Value::string( + "string", + )))) + } #[test] fn aot_artifact_preserves_interpreter_boundary_mode() { @@ -558,6 +574,22 @@ mod tests { None, ) .with_local_count(8); + let mut schema_builder = HostApiBuilder::new(); + let schema_function = HostFunctionSchema::with_return( + "print", + vec![HostParamSchema::value( + "value", + HostTypeSchema::Array(Box::new(HostTypeSchema::Int)), + )], + HostTypeSchema::Unknown, + ); + schema_builder.function(schema_function.clone()); + let schema_catalog = schema_builder.build().expect("schema catalog should build"); + let schema = + crate::host_api::HostImportSchema::from_function(&schema_catalog, &schema_function); + let program = program + .with_host_import_schemas(vec![schema.clone()]) + .expect("schema metadata should align"); let mut vm = Vm::new(program.clone()); vm.compile_aot().expect("aot compile should succeed"); @@ -570,6 +602,10 @@ mod tests { assert_eq!(standalone.program().local_count, 8); assert_eq!(standalone.program().constants, program.constants); assert_eq!(standalone.program().imports, program.imports); + assert_eq!( + standalone.program().host_import_schemas(), + program.host_import_schemas() + ); assert_eq!(standalone.program().type_map, program.type_map); assert!( standalone.has_aot_program(), @@ -577,6 +613,88 @@ mod tests { ); } + #[test] + fn aot_loaded_program_binds_full_schema_overloads() { + let mut catalog_builder = HostApiBuilder::new(); + let int_function = HostFunctionSchema::with_return( + "aot::overloaded", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + ); + let string_function = HostFunctionSchema::with_return( + "aot::overloaded", + vec![HostParamSchema::value("value", HostTypeSchema::String)], + HostTypeSchema::String, + ); + catalog_builder.function(int_function.clone()); + catalog_builder.function(string_function.clone()); + let catalog = catalog_builder.build().expect("overload catalog"); + let int_schema = HostImportSchema::from_function(&catalog, &int_function); + let string_schema = HostImportSchema::from_function(&catalog, &string_function); + + let mut bytecode = BytecodeBuilder::new(); + bytecode.ldc(0); + bytecode.call(0, 1); + bytecode.ldc(1); + bytecode.call(1, 1); + bytecode.ret(); + let program = Program::with_imports_and_debug( + vec![Value::Int(1), Value::string("x")], + bytecode.finish(), + vec![ + crate::bytecode::HostImport { + name: "aot::overloaded".to_string(), + arity: 1, + return_type: ValueType::Int, + }, + crate::bytecode::HostImport { + name: "aot::overloaded".to_string(), + arity: 1, + return_type: ValueType::String, + }, + ], + None, + ) + .with_host_import_schemas(vec![int_schema.clone(), string_schema.clone()]) + .expect("schema alignment"); + let mut compiler_vm = Vm::new(program); + compiler_vm + .compile_aot() + .expect("aot compile should succeed"); + let artifact = compiler_vm + .encode_aot_artifact() + .expect("aot artifact should encode"); + + let mut loaded = Vm::new_from_aot_artifact_with_jit_config(&artifact, JitConfig::default()) + .expect("aot artifact should load"); + let mut registry = HostFunctionRegistry::empty(); + registry + .register_catalog_static(int_schema, aot_overloaded_int) + .expect("integer overload registration"); + registry + .register_catalog_static(string_schema, aot_overloaded_string) + .expect("string overload registration"); + registry + .bind_vm_cached(&mut loaded) + .expect("loaded aot program should bind both overloads"); + assert_eq!( + loaded.run().expect("aot overloads should execute"), + VmStatus::Halted + ); + assert_eq!(loaded.stack(), &[Value::Int(11), Value::string("string")]); + + let mut wrong_schema = HostImportSchema::from_function(&catalog, &int_function); + wrong_schema.params[0].schema = HostTypeSchema::String; + let mut wrong_registry = HostFunctionRegistry::empty(); + wrong_registry + .register_catalog_static(wrong_schema, aot_overloaded_int) + .expect("mismatched registration"); + let mut rejected = + Vm::new_from_aot_artifact_with_jit_config(&artifact, JitConfig::default()) + .expect("aot artifact should load for mismatch check"); + assert!(wrong_registry.bind_vm_cached(&mut rejected).is_err()); + } + #[test] fn aot_artifact_v7_roundtrips_callable_metadata_and_rejects_old_revisions() { let compiled = diff --git a/src/vm/aot/ir.rs b/src/vm/aot/ir.rs index da1921ae..00873b73 100644 --- a/src/vm/aot/ir.rs +++ b/src/vm/aot/ir.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -use crate::builtins::BuiltinFunction; +use crate::BuiltinFunction; use crate::vm::{OpCode, Program, ValueType}; use super::cfg::{AotBasicBlock, AotBlockTerminal, AotCfg, AotCfgError, AotCfgRegion, build_cfg}; diff --git a/src/vm/aot/runtime.rs b/src/vm/aot/runtime.rs index d4e6fed5..2c643428 100644 --- a/src/vm/aot/runtime.rs +++ b/src/vm/aot/runtime.rs @@ -87,6 +87,7 @@ impl Vm { let op_id = self .instance .waiting_host_op + .as_ref() .map(|op| op.op_id) .ok_or_else(|| { VmError::JitNative( diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index b7c0e9e9..606ef309 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeSet, HashMap, VecDeque}; use std::fmt::{self, Write}; -use crate::builtins::BuiltinFunction; +use crate::BuiltinFunction; use crate::vm::{Program, Value, ValueType}; use super::cfg::AotBlockTerminal; diff --git a/src/vm/async_host/mod.rs b/src/vm/async_host/mod.rs new file mode 100644 index 00000000..a135c5fc --- /dev/null +++ b/src/vm/async_host/mod.rs @@ -0,0 +1,132 @@ +//! Generic async host execution SDK. +//! +//! This module provides the public surface for submitting async host +//! functions that do not borrow the VM across a poll. The two pieces are: +//! +//! * [`HostFutureOutput`] — the terminal result of an async host call, +//! either an already-produced [`CallReturn`] or a [`VmCompletion`] closure +//! that must run against the VM (e.g. to insert a resource into the +//! execution scope) before the call can return to the guest. +//! * [`CaptureAsyncHostContext`] — the trait async host functions use to +//! capture owned, `'static` host context from the VM before submission. +//! +//! Submitted futures are handed to the configured [`HostAsyncBridge`], which +//! owns their polling on its own executor. The returned host-operation id is +//! tracked in the host runtime's `submitted_host_ops` set so the VM routes +//! the waiting dispatch to the bridge's [`poll_submitted_op`] instead of a +//! runtime-owned operation driver. Async host operations therefore go +//! through the bridge (the concrete driver of the submitted future); the VM +//! never builds a second cancellation framework or static poller table. + +use std::future::Future; +use std::pin::Pin; + +use super::*; + +/// A completion closure that runs against the VM after the async call's +/// future has resolved. +pub type HostVmCompletion = Box VmResult + Send + 'static>; + +/// The terminal result of a submitted async host call. +/// +/// `T` is the value produced without further VM access (`Return`), or the +/// value produced by a completion closure that borrows the VM once +/// (`VmCompletion`). +pub enum HostFutureOutput { + Return(T), + VmCompletion(HostVmCompletion), +} + +impl HostFutureOutput { + /// Wraps an already-produced value. + pub fn returning(value: T) -> Self { + Self::Return(value) + } + + /// Wraps a completion closure that must run against the VM to produce + /// the value. + pub fn complete(completion: impl FnOnce(&mut Vm) -> VmResult + Send + 'static) -> Self { + Self::VmCompletion(Box::new(completion)) + } + + /// Maps the produced value through `map`, deferring the mapping until + /// the completion closure (if any) has run against the VM. + pub fn map( + self, + map: impl FnOnce(T) -> U + Send + 'static, + ) -> HostFutureOutput + where + T: Send + 'static, + { + match self { + Self::Return(value) => HostFutureOutput::Return(map(value)), + Self::VmCompletion(completion) => { + HostFutureOutput::VmCompletion(Box::new(move |vm| completion(vm).map(map))) + } + } + } +} + +impl HostFutureOutput { + /// Resolves the terminal output against the VM: a `Return` value is + /// returned directly; a `VmCompletion` closure runs with `&mut Vm`. + pub(crate) fn finish(self, vm: &mut Vm) -> VmResult { + match self { + Self::Return(values) => Ok(values), + Self::VmCompletion(completion) => completion(vm), + } + } +} + +impl From for HostFutureOutput { + fn from(values: CallReturn) -> Self { + Self::Return(values) + } +} + +/// A boxed, owned future produced by an async host function submission. +pub type HostFuture = Pin> + Send + 'static>>; + +/// Allows an async host function to capture owned host context from the VM +/// before its future is submitted. +/// +/// Async host functions cannot borrow the VM across a poll; they must +/// capture everything they need as owned, `'static` values. Implementors +/// run in the VM thread during the originating host call. +pub trait CaptureAsyncHostContext: Send + 'static + Sized { + fn capture(vm: &mut Vm) -> VmResult; + + fn capture_with_args(vm: &mut Vm, _args: &[Value]) -> VmResult { + Self::capture(vm) + } +} + +impl Vm { + /// Submits an async host future to the configured async host bridge. + /// + /// The future is handed to the bridge, which owns its polling on its own + /// executor. A fresh host-operation id is allocated, recorded in the + /// bridge's submitted set, and returned as a `Pending` call outcome. + /// + /// Requires a configured [`HostAsyncBridge`] that accepts submitted + /// futures; otherwise a host error is returned. + pub fn submit_host_future(&mut self, future: HostFuture) -> VmResult { + if self.host.async_bridge.is_none() { + return Err(VmError::HostError( + "async host function requires a host async bridge".to_string(), + )); + } + let op_id = self.host.reserve_submitted_host_op()?; + let submit_result = self + .host + .async_bridge + .as_mut() + .expect("bridge presence checked before reservation") + .submit_op(op_id, future); + if let Err(error) = submit_result { + self.host.rollback_submitted_host_op(op_id); + return Err(error); + } + Ok(CallOutcome::Pending(op_id)) + } +} diff --git a/src/vm/capability.rs b/src/vm/capability.rs new file mode 100644 index 00000000..42d03bb3 --- /dev/null +++ b/src/vm/capability.rs @@ -0,0 +1,168 @@ +use crate::BuiltinFunction; + +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; +const PROFILE_VERSION: &[u8] = b"rustscript-capability-profile-v2"; + +/// Immutable authorization policy for privileged builtin calls and host imports. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CapabilityProfile { + allow_all_builtins: bool, + allow_all_host_imports: bool, + allowed_builtin_calls: Vec, + allowed_host_imports: Vec, + fingerprint: u64, +} + +impl CapabilityProfile { + pub fn builder() -> CapabilityProfileBuilder { + CapabilityProfileBuilder::default() + } + + pub fn deny_all() -> Self { + CapabilityProfileBuilder::default().build() + } + + pub fn allow_all() -> Self { + CapabilityProfileBuilder { + allow_all_builtins: true, + allow_all_host_imports: true, + ..CapabilityProfileBuilder::default() + } + .build() + } + + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + + pub fn allows_builtin(&self, builtin: BuiltinFunction) -> bool { + self.allow_all_builtins + || self + .allowed_builtin_calls + .binary_search(&builtin.call_index()) + .is_ok() + } + + pub fn allows_host_import(&self, name: &str) -> bool { + self.allow_all_host_imports + || self + .allowed_host_imports + .binary_search_by(|candidate| candidate.as_str().cmp(name)) + .is_ok() + } + + pub(crate) fn allowed_builtin_calls(&self) -> &[u16] { + &self.allowed_builtin_calls + } + + pub(crate) fn allows_all_builtins(&self) -> bool { + self.allow_all_builtins + } + + pub(crate) fn allows_all_host_imports(&self) -> bool { + self.allow_all_host_imports + } + + pub(crate) fn with_builtin(&self, builtin: BuiltinFunction) -> Self { + let mut builder = CapabilityProfileBuilder { + allow_all_builtins: self.allow_all_builtins, + allow_all_host_imports: self.allow_all_host_imports, + allowed_builtin_calls: self.allowed_builtin_calls.clone(), + allowed_host_imports: self.allowed_host_imports.clone(), + }; + builder.allowed_builtin_calls.push(builtin.call_index()); + builder.build() + } + + pub(crate) fn with_host_import(&self, name: &str) -> Self { + let mut builder = CapabilityProfileBuilder { + allow_all_builtins: self.allow_all_builtins, + allow_all_host_imports: self.allow_all_host_imports, + allowed_builtin_calls: self.allowed_builtin_calls.clone(), + allowed_host_imports: self.allowed_host_imports.clone(), + }; + builder.allowed_host_imports.push(name.to_string()); + builder.build() + } +} + +impl Default for CapabilityProfile { + fn default() -> Self { + Self::deny_all() + } +} + +#[derive(Clone, Debug, Default)] +pub struct CapabilityProfileBuilder { + allow_all_builtins: bool, + allow_all_host_imports: bool, + allowed_builtin_calls: Vec, + allowed_host_imports: Vec, +} + +impl CapabilityProfileBuilder { + pub fn allow_builtin(mut self, builtin: BuiltinFunction) -> Self { + self.allowed_builtin_calls.push(builtin.call_index()); + self + } + + pub fn allow_host_import(mut self, name: impl Into) -> Self { + self.allowed_host_imports.push(name.into()); + self + } + + pub fn build(mut self) -> CapabilityProfile { + self.allowed_builtin_calls.sort_unstable(); + self.allowed_builtin_calls.dedup(); + self.allowed_host_imports.sort(); + self.allowed_host_imports.dedup(); + let fingerprint = fingerprint( + self.allow_all_builtins, + self.allow_all_host_imports, + &self.allowed_builtin_calls, + &self.allowed_host_imports, + ); + CapabilityProfile { + allow_all_builtins: self.allow_all_builtins, + allow_all_host_imports: self.allow_all_host_imports, + allowed_builtin_calls: self.allowed_builtin_calls, + allowed_host_imports: self.allowed_host_imports, + fingerprint, + } + } +} + +fn fingerprint( + allow_all_builtins: bool, + allow_all_host_imports: bool, + builtin_calls: &[u16], + host_imports: &[String], +) -> u64 { + let mut value = FNV_OFFSET_BASIS; + update_fingerprint(&mut value, PROFILE_VERSION); + update_fingerprint( + &mut value, + &[ + u8::from(allow_all_builtins), + u8::from(allow_all_host_imports), + ], + ); + update_fingerprint(&mut value, &(builtin_calls.len() as u64).to_le_bytes()); + for call in builtin_calls { + update_fingerprint(&mut value, &call.to_le_bytes()); + } + update_fingerprint(&mut value, &(host_imports.len() as u64).to_le_bytes()); + for name in host_imports { + update_fingerprint(&mut value, &(name.len() as u64).to_le_bytes()); + update_fingerprint(&mut value, name.as_bytes()); + } + value +} + +fn update_fingerprint(state: &mut u64, bytes: &[u8]) { + for byte in bytes { + *state ^= u64::from(*byte); + *state = state.wrapping_mul(FNV_PRIME); + } +} diff --git a/src/vm/engine.rs b/src/vm/engine.rs index 33acefe9..fbf67d12 100644 --- a/src/vm/engine.rs +++ b/src/vm/engine.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::builtins::runtime::regex::RegexCache; +use super::regex_cache::RegexCache; use crate::bytecode::{DecodedInstructionData, Program}; use crate::vm::aot; use crate::vm::jit; diff --git a/src/vm/execution_scope.rs b/src/vm/execution_scope.rs index 8f63cc72..aee9c26d 100644 --- a/src/vm/execution_scope.rs +++ b/src/vm/execution_scope.rs @@ -21,7 +21,9 @@ use super::operation::driver::{OperationOutcome, OperationSpec}; use super::operation::error::OperationError; use super::operation::id::OperationId; use super::operation::reason::OperationCancelReason; -use super::operation::registry::{DEFAULT_MAX_PENDING_OPERATIONS, OperationRegistry}; +use super::operation::registry::{ + DEFAULT_MAX_PENDING_OPERATIONS, OperationCancelSummary, OperationRegistry, +}; use super::resource::HostResource; use super::resource::close::CloseProgress; use super::resource::error::ResourceError; @@ -69,6 +71,11 @@ pub enum ExecutionScopeError { Resource(ResourceError), /// The underlying operation start/cancel failed. Operation(OperationError), + /// Scope cleanup reached quiescence but reported one or more failures. + /// + /// The complete terminal outcome is retained so reset/reuse callers do not + /// lose the first error or the aggregate failure count. + Close(ScopeCloseOutcome), } impl std::fmt::Display for ExecutionScopeError { @@ -97,6 +104,9 @@ impl std::fmt::Display for ExecutionScopeError { Self::Operation(error) => { write!(formatter, "execution scope operation error: {error}") } + Self::Close(outcome) => { + write!(formatter, "execution scope close outcome: {outcome:?}") + } } } } @@ -107,6 +117,12 @@ impl ExecutionScopeError { pub fn into_operation_error(self) -> Option { match self { ExecutionScopeError::Operation(error) => Some(error), + ExecutionScopeError::Close(ScopeCloseOutcome::SuccessWithErrors(failure)) => { + match failure.first { + ScopeCloseError::Operation(error) => Some(error), + ScopeCloseError::Resource(_) => None, + } + } _ => None, } } @@ -118,6 +134,12 @@ impl ExecutionScopeError { ExecutionScopeError::Resource(error) | ExecutionScopeError::ArenaExhausted(error) => { Some(error) } + ExecutionScopeError::Close(ScopeCloseOutcome::SuccessWithErrors(failure)) => { + match failure.first { + ScopeCloseError::Resource(error) => Some(error), + ScopeCloseError::Operation(_) => None, + } + } _ => None, } } @@ -128,6 +150,7 @@ impl std::error::Error for ExecutionScopeError { match self { Self::ArenaExhausted(error) | Self::Resource(error) => Some(error), Self::Operation(error) => Some(error), + Self::Close(_) => None, _ => None, } } @@ -224,6 +247,10 @@ impl ExecutionScope { self.state == ScopeState::Active } + pub(crate) fn is_reusable(&self) -> bool { + self.is_active() && self.resources.is_clean() && self.operations.is_empty() + } + /// Whether shutdown has begun but is not yet quiescent. pub fn is_closing(&self) -> bool { self.state == ScopeState::Closing @@ -245,6 +272,18 @@ impl ExecutionScope { &self.resources } + /// Mutable access to the owned resource table (typed borrows for the + /// duration of a host call). New inserts must still go through the guarded + /// scope API. + /// Returns mutable access for VM-internal typed resource operations. + /// + /// Public callers must use the guarded `push_resource`/`resource_*` methods + /// on [`ExecutionScope`]; exposing this raw table would allow admission to + /// bypass the scope's `Open` lifecycle state. + pub(crate) fn resources_mut(&mut self) -> &mut ResourceTable { + &mut self.resources + } + // ---- typed scope-state arena ------------------------------------------------- /// Returns a mutable handle to the `T`-typed scope state, creating it with @@ -308,6 +347,27 @@ impl ExecutionScope { .map_err(ExecutionScopeError::Resource) } + /// Takes an open typed resource out of the current scope and transfers its + /// concrete value to the caller. Validation precedes slot mutation; a + /// rejected take leaves the resource available. + pub fn take_resource( + &mut self, + handle: ResourceHandle, + ) -> ExecutionScopeResult { + self.resources + .take::(handle) + .map_err(ExecutionScopeError::Resource) + } + + /// Alias for [`Self::take_resource`] using the ownership terminology used + /// by host-function declarations. + pub fn take_owned( + &mut self, + handle: ResourceHandle, + ) -> ExecutionScopeResult { + self.take_resource::(handle) + } + /// Registers a host operation while the scope is Active. pub fn start_operation(&mut self, spec: OperationSpec) -> ExecutionScopeResult { self.ensure_accepting()?; @@ -365,15 +425,16 @@ impl ExecutionScope { /// Aborts a started operation in one step so it never produces a /// guest-visible result: cancels the driver exactly once if pending - /// (recording the first reason), then consumes and immediately releases - /// the slot, restoring full registry capacity and making the id stale. + /// (recording the first reason), waits through the driver's + /// `cancel_and_wait` boundary, then consumes/releases the slot, restoring + /// full registry capacity and making the id stale. /// /// This is the rollback counterpart to /// [`start_operation`](Self::start_operation), intended for call sites /// that register an operation and then hit a fallible handoff. Even when - /// the driver's `cancel` reports a typed failure, the slot is still - /// released. A stale/foreign/out-of-range id is rejected with the typed - /// error and no registry mutation. + /// the driver's cancellation boundary reports a typed failure, the slot + /// is still released. A stale/foreign/out-of-range id is rejected with + /// the typed error and no registry mutation. pub fn abort_operation( &mut self, id: OperationId, @@ -448,6 +509,16 @@ impl ExecutionScope { } } + pub(crate) fn cancel_operations_and_wait( + &mut self, + reason: OperationCancelReason, + ) -> OperationCancelSummary { + let summary = self.operations.cancel_all_and_wait(reason); + self.record_operation_summary(&summary); + self.operations_drained = true; + summary + } + /// Runs the VM-Drop-only nonblocking resource close launch after the normal /// scope close poll has cancelled operations and begun all current leaves. /// This never changes the scope state or claims quiescence. @@ -492,14 +563,7 @@ impl ExecutionScope { // Phase 1 — operations: cancel every pending operation exactly once. if !self.operations_drained { let summary = self.operations.cancel_all(operation_reason(reason)); - if let Some(error) = summary.first_error() { - self.record_failure(ScopeCloseError::Operation(error.clone())); - } - // Every failed operation cancellation/cleanup counts toward the - // failure total; `failed` includes the first-error case above. - self.failed_count += summary - .failed() - .saturating_sub(usize::from(summary.first_error().is_some())); + self.record_operation_summary(&summary); self.operations_drained = true; } @@ -561,6 +625,17 @@ impl ExecutionScope { self.failed_count += 1; } + /// Records a batch cancellation without allowing a reset's synchronous + /// cancel-and-wait path to discard its first error or failure count. + fn record_operation_summary(&mut self, summary: &OperationCancelSummary) { + if let Some(error) = summary.first_error() { + self.record_failure(ScopeCloseError::Operation(error.clone())); + } + self.failed_count += summary + .failed() + .saturating_sub(usize::from(summary.first_error().is_some())); + } + /// Freezes the terminal outcome once both registries are empty. fn finish_close(&mut self) { debug_assert!(self.operations.is_empty(), "operations must be drained"); diff --git a/src/vm/host.rs b/src/vm/host.rs index ea76fe5c..cf591701 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -1,17 +1,31 @@ -use std::sync::{Arc, OnceLock, RwLock}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; use std::task::{Context, Poll, Wake, Waker}; -use crate::builtins::BuiltinFunction; +use crate::BuiltinFunction; +use crate::host_api::{HostImportSchema, HostTypeSchema}; +use crate::vm::operation::{OperationCancelReason, OperationId, OperationOutcome}; +use crate::vm::resource::handle::ResourceHandle; +use crate::vm::resource::table::ResourceTable; +use super::async_host::{HostFuture, HostFutureOutput}; +use super::capability::CapabilityProfile; use super::*; pub type HostOpId = u64; +/// Adapter-owned completion for an operation registered in the execution +/// scope. The generic VM owns only this opaque hook: adapters retain ownership +/// of their result mailbox and any resource-table side effects. +pub(crate) type ScopedOperationCompletion = + Box VmResult + Send + 'static>; + #[derive(Clone, Debug, Default, PartialEq)] pub enum CallReturn { #[default] None, One(Value), + Many(Vec), } impl CallReturn { @@ -23,7 +37,7 @@ impl CallReturn { Self::One(value) } - pub fn from_values(values: Vec) -> Self { + pub fn many(values: Vec) -> Self { match values.len() { 0 => Self::None, 1 => Self::One( @@ -32,18 +46,27 @@ impl CallReturn { .next() .expect("single-value return should contain one value"), ), - _ => Self::One(Value::array(values)), + _ => Self::Many(values), } } + pub fn from_values(values: Vec) -> Self { + Self::many(values) + } + pub fn is_empty(&self) -> bool { - matches!(self, Self::None) + match self { + Self::None => true, + Self::One(_) => false, + Self::Many(values) => values.is_empty(), + } } pub fn as_slice(&self) -> &[Value] { match self { Self::None => &[], Self::One(value) => std::slice::from_ref(value), + Self::Many(values) => values, } } @@ -51,6 +74,7 @@ impl CallReturn { match self { Self::None => {} Self::One(value) => stack.push(value), + Self::Many(values) => stack.extend(values), } } } @@ -85,10 +109,101 @@ pub trait HostArgsFunction: Send { fn call(&mut self, args: &[Value]) -> VmResult; } +/// Terminal state supplied to [`HostAsyncBridge::cleanup_op`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostAsyncOpTerminal { + /// The submitted future produced a normal result. + Completed, + /// The bridge acknowledged cancellation and quiescence. + Cancelled, + /// The submitted future failed and no longer owns host work. + Failed, +} + +impl HostAsyncOpTerminal { + /// Returns the reason used when default cleanup finalizes this terminal + /// operation. Cleanup is a terminal resource-release action rather than a + /// new cancellation request, so every terminal state uses the stable + /// `Requested` compatibility reason; an actual cancellation reason is + /// delivered earlier through `request_cancel_op`. + pub const fn cleanup_reason(self) -> OperationCancelReason { + match self { + Self::Completed => OperationCancelReason::Requested, + Self::Cancelled => OperationCancelReason::Requested, + Self::Failed => OperationCancelReason::Requested, + } + } +} + pub trait HostAsyncBridge: Send { + fn submit_op(&mut self, _op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + Err(VmError::HostError( + "async host bridge does not accept submitted futures".to_string(), + )) + } + fn poll_op(&mut self, op_id: HostOpId, cx: &mut Context<'_>) -> Poll>; + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + self.poll_op(op_id, cx) + .map(|result| result.map(HostFutureOutput::Return)) + } + + /// Legacy cancellation hook kept for bridge implementations that do not + /// need a lifecycle reason. It is used as a best-effort fallback by the + /// default [`request_cancel_op`](Self::request_cancel_op) implementation. fn cancel_op(&mut self, _op_id: HostOpId) {} + + /// Legacy cancellation hook kept for bridge implementations that do not + /// need a lifecycle reason. New bridges should implement + /// [`request_cancel_op`](Self::request_cancel_op) and + /// [`poll_cancel_op`](Self::poll_cancel_op) instead. + fn cancel_op_with_reason(&mut self, op_id: HostOpId, _reason: OperationCancelReason) { + self.cancel_op(op_id); + } + + /// Requests cancellation of one bridge-owned operation. + /// + /// Returning `Ok(())` only records that the request was accepted. It does + /// not mean that the operation has stopped; callers must poll + /// [`poll_cancel_op`](Self::poll_cancel_op) until it returns `Ready(Ok(()))`. + /// The default invokes the legacy best-effort hook, then fails explicitly so + /// an adapter that has not opted into acknowledgement can never claim + /// quiescence. + fn request_cancel_op( + &mut self, + op_id: HostOpId, + reason: OperationCancelReason, + ) -> VmResult<()> { + self.cancel_op_with_reason(op_id, reason); + Err(VmError::HostError(format!( + "async host bridge does not provide cancellation acknowledgement for op {op_id}" + ))) + } + + /// Polls completion of a previously accepted cancellation request. + /// `Ready(Ok(()))` is the bridge's acknowledgement that the operation is + /// terminal and quiescent. The default fails closed rather than treating a + /// no-op implementation as an acknowledgement. + fn poll_cancel_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "async host bridge does not provide cancellation acknowledgement for op {op_id}" + )))) + } + + /// Runs bridge-side cleanup after a terminal/quiescent outcome has been + /// reported. The VM invokes this at most once for each tracked operation. + /// The default preserves compatibility with bridges whose legacy + /// `cancel_op` method also removes completed operation state while routing + /// through the reason-aware hook for newer bridges. + fn cleanup_op(&mut self, op_id: HostOpId, terminal: HostAsyncOpTerminal) -> VmResult<()> { + self.cancel_op_with_reason(op_id, terminal.cleanup_reason()); + Ok(()) + } } pub type StaticHostFunction = fn(&mut Vm, &[Value]) -> VmResult; @@ -113,21 +228,118 @@ enum RegistryEntryKind { #[derive(Clone)] struct RegistryEntry { arity: u8, + schema: Option, kind: RegistryEntryKind, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RegistrySchemaError { + InvalidArity { + name: String, + arity: usize, + }, + Duplicate { + schema: Box, + }, + DispatchConflict { + existing: Box, + requested: Box, + }, +} + +type HostPlanCache = + HashMap<(Vec, Vec>), Arc>; + +impl std::fmt::Display for RegistrySchemaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidArity { name, arity } => { + write!(f, "catalog function '{name}' has unsupported arity {arity}") + } + Self::Duplicate { schema } => { + write!( + f, + "catalog schema for '{}' is already registered", + schema.name + ) + } + Self::DispatchConflict { + existing, + requested, + } => write!( + f, + "catalog schemas for '{}' have the same dispatch shape but differ in identity: existing {existing:?}, requested {requested:?}", + requested.name + ), + } + } +} + +impl std::error::Error for RegistrySchemaError {} + +fn normalize_import_schemas( + imports: &[HostImport], + schemas: &[Option], +) -> VmResult>> { + if schemas.is_empty() { + return Ok(vec![None; imports.len()]); + } + if schemas.len() != imports.len() { + return Err(VmError::HostError(format!( + "host import schema count mismatch: expected {}, got {}", + imports.len(), + schemas.len() + ))); + } + Ok(schemas.to_vec()) +} + +fn same_dispatch_shape(lhs: &HostImportSchema, rhs: &HostImportSchema) -> bool { + lhs.name == rhs.name + && lhs.params.len() == rhs.params.len() + && lhs + .params + .iter() + .zip(rhs.params.iter()) + .all(|(left, right)| left.schema == right.schema && left.passing == right.passing) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct HostBindingPlan { import_signature: Vec, + import_schemas: Vec>, registry_slots: Vec, + registry_schemas: Vec>, resolved_calls: Vec, + allowed_builtin_calls: Vec, + allow_default_builtin_capabilities: bool, + allowed_host_function_slots: Vec, + allow_default_host_capabilities: bool, + capability_profile: Arc, + capability_fingerprint: u64, + registry_state: Arc<()>, + registry_generation_token: Arc<()>, + registry_generation: u64, } #[derive(Clone)] pub struct HostFunctionRegistry { entries: Arc>, by_name: Arc>, - plan_cache: Arc, Arc>>>, + catalog_by_schema: Arc>, + plan_cache: Arc>, + allowed_builtin_calls: Arc>, + allow_default_builtin_capabilities: bool, + allow_default_host_capabilities: bool, + capability_profile: Arc, + registry_state: Arc<()>, + registry_generation_token: Arc<()>, + registry_generation: Arc, + /// Caller-provided standard-surface composition strategy, if installed. + /// + /// This is explicit per-instance state: the outer standard-runtime + /// constructor installs it; `src/vm` never names a concrete domain. + standard_composition: Option>, } impl Default for HostFunctionRegistry { @@ -137,27 +349,103 @@ impl Default for HostFunctionRegistry { } impl HostFunctionRegistry { - fn empty() -> Self { + pub fn empty() -> Self { Self { entries: Arc::new(Vec::new()), by_name: Arc::new(HashMap::new()), + catalog_by_schema: Arc::new(HashMap::new()), plan_cache: Arc::new(RwLock::new(HashMap::new())), + allowed_builtin_calls: Arc::new(Vec::new()), + allow_default_builtin_capabilities: true, + allow_default_host_capabilities: true, + capability_profile: Arc::new(CapabilityProfile::allow_all()), + registry_state: Arc::new(()), + registry_generation_token: Arc::new(()), + registry_generation: Arc::new(AtomicU64::new(0)), + standard_composition: None, } } pub fn new() -> Self { - static DEFAULT_REGISTRY: OnceLock = OnceLock::new(); + let mut registry = Self::empty(); + crate::install_default_host_functions(&mut registry); + registry + } + + /// Returns the standard host registry with every registered host function present but + /// requiring an explicit capability grant before execution. + pub fn restricted() -> Self { + let mut registry = Self::new(); + registry.allow_default_builtin_capabilities = false; + registry.allow_default_host_capabilities = false; + registry.capability_profile = Arc::new(CapabilityProfile::deny_all()); + registry.registry_state = Arc::new(()); + registry.registry_generation_token = Arc::new(()); + registry.registry_generation = Arc::new(AtomicU64::new(0)); + registry.invalidate_plan_cache(); + registry + } + + /// Replaces the registry's immutable capability profile. + pub fn set_capability_profile(&mut self, profile: CapabilityProfile) { + self.allowed_builtin_calls = Arc::new(profile.allowed_builtin_calls().to_vec()); + self.allow_default_builtin_capabilities = profile.allows_all_builtins(); + self.allow_default_host_capabilities = profile.allows_all_host_imports(); + self.capability_profile = Arc::new(profile); + self.invalidate_plan_cache(); + } - DEFAULT_REGISTRY - .get_or_init(|| { - let mut registry = Self::empty(); - crate::builtins::runtime::register_default_host_functions(&mut registry); - registry - }) - .clone() + /// Installs the caller-provided standard-surface composition strategy. + /// + /// Explicit per-instance state: the outer standard-runtime constructor + /// installs it; `src/vm` never names a concrete domain module or feature. + pub fn set_standard_composition( + &mut self, + composition: Arc, + ) { + self.standard_composition = Some(composition); + self.invalidate_plan_cache(); + } + + /// The installed standard-surface composition strategy, if any. + pub fn standard_composition( + &self, + ) -> Option<&Arc> { + self.standard_composition.as_ref() + } + + /// Whether a host function with the given name is currently registered. + pub fn contains_name(&self, name: &str) -> bool { + self.by_name.contains_key(name) + || self + .catalog_by_schema + .keys() + .any(|schema| schema.name == name) + } + + /// Explicitly permits a namespaced builtin when this registry is used as a capability plan. + pub fn allow_builtin(&mut self, name: impl AsRef) -> VmResult<()> { + let name = name.as_ref(); + if self.by_name.contains_key(name) { + self.capability_profile = Arc::new(self.capability_profile.with_host_import(name)); + self.invalidate_plan_cache(); + return Ok(()); + } + let builtin = BuiltinFunction::from_namespaced_name(name) + .ok_or_else(|| VmError::HostError(format!("unknown namespaced builtin '{name}'")))?; + let calls = Arc::make_mut(&mut self.allowed_builtin_calls); + if !calls.contains(&builtin.call_index()) { + calls.push(builtin.call_index()); + calls.sort_unstable(); + } + self.capability_profile = Arc::new(self.capability_profile.with_builtin(builtin)); + self.invalidate_plan_cache(); + Ok(()) } fn invalidate_plan_cache(&mut self) { + self.registry_state = Arc::new(()); + self.registry_generation.fetch_add(1, Ordering::Relaxed); self.plan_cache = Arc::new(RwLock::new(HashMap::new())); } @@ -179,6 +467,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + schema: None, kind: RegistryEntryKind::Factory(Arc::new(factory)), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -205,6 +494,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + schema: None, kind: RegistryEntryKind::Static(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -229,6 +519,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + schema: None, kind: RegistryEntryKind::StackFactory(Arc::new(factory)), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -255,6 +546,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + schema: None, kind: RegistryEntryKind::StackStatic(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -279,6 +571,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + schema: None, kind: RegistryEntryKind::ArgsFactory(Arc::new(factory)), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -305,6 +598,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + schema: None, kind: RegistryEntryKind::ArgsStatic(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -337,15 +631,183 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + schema: None, kind: RegistryEntryKind::ArgsStaticNonYielding(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); self.invalidate_plan_cache(); } + fn register_catalog_entry( + &mut self, + schema: HostImportSchema, + kind: RegistryEntryKind, + ) -> Result { + let arity = + u8::try_from(schema.arity()).map_err(|_| RegistrySchemaError::InvalidArity { + name: schema.name.clone(), + arity: schema.arity(), + })?; + if self.catalog_by_schema.contains_key(&schema) { + return Err(RegistrySchemaError::Duplicate { + schema: Box::new(schema), + }); + } + if let Some(existing) = self + .catalog_by_schema + .keys() + .find(|existing| same_dispatch_shape(existing, &schema)) + { + return Err(RegistrySchemaError::DispatchConflict { + existing: Box::new(existing.clone()), + requested: Box::new(schema), + }); + } + + let entries = Arc::make_mut(&mut self.entries); + let slot = u16::try_from(entries.len()).map_err(|_| RegistrySchemaError::InvalidArity { + name: schema.name.clone(), + arity: schema.arity(), + })?; + entries.push(RegistryEntry { + arity, + schema: Some(schema.clone()), + kind, + }); + Arc::make_mut(&mut self.catalog_by_schema).insert(schema, slot); + self.invalidate_plan_cache(); + Ok(slot) + } + + pub fn register_catalog( + &mut self, + schema: HostImportSchema, + factory: F, + ) -> Result + where + F: Fn() -> Box + Send + Sync + 'static, + { + self.register_catalog_entry(schema, RegistryEntryKind::Factory(Arc::new(factory))) + } + + pub fn register_catalog_static( + &mut self, + schema: HostImportSchema, + function: StaticHostFunction, + ) -> Result { + self.register_catalog_entry(schema, RegistryEntryKind::Static(function)) + } + + pub fn register_catalog_stack( + &mut self, + schema: HostImportSchema, + factory: F, + ) -> Result + where + F: Fn() -> Box + Send + Sync + 'static, + { + self.register_catalog_entry(schema, RegistryEntryKind::StackFactory(Arc::new(factory))) + } + + pub fn register_catalog_static_stack( + &mut self, + schema: HostImportSchema, + function: StaticHostStackFunction, + ) -> Result { + self.register_catalog_entry(schema, RegistryEntryKind::StackStatic(function)) + } + + pub fn register_catalog_args( + &mut self, + schema: HostImportSchema, + factory: F, + ) -> Result + where + F: Fn() -> Box + Send + Sync + 'static, + { + self.register_catalog_entry(schema, RegistryEntryKind::ArgsFactory(Arc::new(factory))) + } + + pub fn register_catalog_static_args( + &mut self, + schema: HostImportSchema, + function: StaticHostArgsFunction, + ) -> Result { + self.register_catalog_entry(schema, RegistryEntryKind::ArgsStatic(function)) + } + + pub fn register_catalog_static_non_yielding_args( + &mut self, + schema: HostImportSchema, + function: StaticHostArgsFunction, + ) -> Result { + self.register_catalog_entry(schema, RegistryEntryKind::ArgsStaticNonYielding(function)) + } + + fn validate_builtin_capability(&self, call_index: u16) -> VmResult<()> { + if let Some(builtin) = BuiltinFunction::from_call_index(call_index) + && builtin.requires_explicit_host_capability() + && !self.allowed_builtin_calls.contains(&call_index) + { + return Err(VmError::HostError(format!( + "capability profile does not allow builtin '{}'", + builtin.name() + ))); + } + Ok(()) + } + + fn validate_program_capabilities(&self, program: &Program) -> VmResult<()> { + if self.allow_default_builtin_capabilities { + return Ok(()); + } + let mut ip = 0usize; + while let Some(&raw_opcode) = program.code.get(ip) { + let opcode = + OpCode::try_from(raw_opcode).map_err(|_| VmError::InvalidOpcode(raw_opcode))?; + let operand_end = ip + .checked_add(1 + opcode.operand_len()) + .ok_or(VmError::BytecodeBounds)?; + if operand_end > program.code.len() { + return Err(VmError::BytecodeBounds); + } + if opcode == OpCode::Call { + let bytes: [u8; 2] = program.code[ip + 1..ip + 3] + .try_into() + .map_err(|_| VmError::BytecodeBounds)?; + self.validate_builtin_capability(u16::from_le_bytes(bytes))?; + } + ip = operand_end; + } + for prototype in &program.callable_prototypes { + if let CallableTarget::HostImport(call_index) = prototype.target { + self.validate_builtin_capability(call_index)?; + } + } + Ok(()) + } + pub fn bind_vm_cached(&self, vm: &mut Vm) -> VmResult<()> { - let plan = self.prepare_shared_plan(&vm.program.imports)?; - self.bind_vm_with_plan(vm, &plan) + if let Some(composition) = self.standard_composition.as_ref() { + let mut composed = self.clone(); + composition.ensure_surfaces(&vm.program.imports, &mut composed)?; + composed.standard_composition = Some(Arc::clone(composition)); + return composed.bind_vm_cached_inner(vm); + } + self.bind_vm_cached_inner(vm) + } + + fn bind_vm_cached_inner(&self, vm: &mut Vm) -> VmResult<()> { + self.validate_program_capabilities(&vm.program)?; + let plan = self.prepare_shared_plan_with_schemas( + &vm.program.imports, + &vm.program.host_import_schemas, + )?; + self.bind_vm_with_plan(vm, &plan)?; + if let Some(composition) = self.standard_composition.as_ref() { + vm.host.standard_composition = Some(Arc::clone(composition)); + } + Ok(()) } pub fn prepare_plan(&self, imports: &[HostImport]) -> VmResult { @@ -353,16 +815,53 @@ impl HostFunctionRegistry { } pub fn prepare_shared_plan(&self, imports: &[HostImport]) -> VmResult> { - self.plan_for_imports(imports) + self.prepare_shared_plan_with_schemas(imports, &[]) + } + + pub fn prepare_plan_with_schemas( + &self, + imports: &[HostImport], + schemas: &[Option], + ) -> VmResult { + Ok(self + .prepare_shared_plan_with_schemas(imports, schemas)? + .as_ref() + .clone()) + } + + pub fn prepare_shared_plan_with_schemas( + &self, + imports: &[HostImport], + schemas: &[Option], + ) -> VmResult> { + let schemas = normalize_import_schemas(imports, schemas)?; + self.plan_for_imports(imports, &schemas) + } + + fn plan_matches_current(&self, plan: &HostBindingPlan) -> bool { + self.capability_profile.fingerprint() == plan.capability_fingerprint + && self.capability_profile.as_ref() == plan.capability_profile.as_ref() + && Arc::ptr_eq(&self.registry_state, &plan.registry_state) + && Arc::ptr_eq( + &self.registry_generation_token, + &plan.registry_generation_token, + ) + && self.registry_generation.load(Ordering::Relaxed) == plan.registry_generation } - fn plan_for_imports(&self, imports: &[HostImport]) -> VmResult> { + fn plan_for_imports( + &self, + imports: &[HostImport], + import_schemas: &[Option], + ) -> VmResult> { + let cache_key = (imports.to_vec(), import_schemas.to_vec()); if let Some(plan) = self .plan_cache .read() .expect("host binding plan cache read lock should not be poisoned") - .get(imports) + .get(&cache_key) .cloned() + && self.plan_matches_current(&plan) { return Ok(plan); } @@ -371,16 +870,49 @@ impl HostFunctionRegistry { let mut registry_slots = Vec::new(); let mut resolved_calls = Vec::with_capacity(imports.len()); - for import in imports { - let registry_slot = self - .by_name - .get(&import.name) - .copied() - .ok_or_else(|| VmError::UnboundImport(import.name.clone()))?; + for (import, import_schema) in imports.iter().zip(import_schemas.iter()) { + let registry_slot = if let Some(schema) = import_schema { + if schema.name != import.name || schema.arity() != usize::from(import.arity) { + return Err(VmError::HostError(format!( + "host import '{}' does not match its full catalog schema", + import.name + ))); + } + self.catalog_by_schema + .get(schema) + .copied() + .ok_or_else(|| VmError::UnboundImport(import.name.clone()))? + } else { + let catalog_candidates = self + .catalog_by_schema + .keys() + .filter(|schema| { + schema.name == import.name && schema.arity() == usize::from(import.arity) + }) + .count(); + if catalog_candidates > 0 { + return Err(VmError::HostError(format!( + "host import '{}' has {} catalog overloads; full schema and fingerprint are required", + import.name, catalog_candidates + ))); + } + self.by_name + .get(&import.name) + .copied() + .ok_or_else(|| VmError::UnboundImport(import.name.clone()))? + }; let entry = self .entries .get(registry_slot as usize) .ok_or(VmError::InvalidCall(registry_slot))?; + if !self.allow_default_host_capabilities + && !self.capability_profile.allows_host_import(&import.name) + { + return Err(VmError::HostError(format!( + "capability profile does not allow host import '{}'", + import.name + ))); + } if entry.arity != import.arity { return Err(VmError::InvalidCallArity { import: import.name.clone(), @@ -388,6 +920,14 @@ impl HostFunctionRegistry { got: import.arity, }); } + if let Some(schema) = import_schema + && entry.schema.as_ref() != Some(schema) + { + return Err(VmError::HostError(format!( + "host registry schema for '{}' does not match the full call-site identity", + import.name + ))); + } let vm_slot = if let Some(&existing) = registry_slot_to_vm_slot.get(®istry_slot) { existing @@ -400,25 +940,83 @@ impl HostFunctionRegistry { resolved_calls.push(vm_slot); } + let allowed_host_function_slots = imports + .iter() + .zip(resolved_calls.iter().copied()) + .filter_map(|(import, vm_slot)| { + self.capability_profile + .allows_host_import(&import.name) + .then_some(vm_slot) + }) + .collect::>(); let import_key = imports.to_vec(); + let registry_schemas = registry_slots + .iter() + .map(|slot| { + self.entries + .get(usize::from(*slot)) + .and_then(|entry| entry.schema.clone()) + }) + .collect(); let computed = Arc::new(HostBindingPlan { - import_signature: import_key.clone(), + import_signature: import_key, + import_schemas: import_schemas.to_vec(), registry_slots, + registry_schemas, resolved_calls, + allowed_builtin_calls: self.allowed_builtin_calls.as_ref().clone(), + allow_default_builtin_capabilities: self.allow_default_builtin_capabilities, + allowed_host_function_slots, + allow_default_host_capabilities: self.allow_default_host_capabilities, + capability_profile: Arc::clone(&self.capability_profile), + capability_fingerprint: self.capability_profile.fingerprint(), + registry_state: Arc::clone(&self.registry_state), + registry_generation_token: Arc::clone(&self.registry_generation_token), + registry_generation: self.registry_generation.load(Ordering::Relaxed), }); let mut cache = self .plan_cache .write() .expect("host binding plan cache write lock should not be poisoned"); - Ok(cache.entry(import_key).or_insert_with(|| computed).clone()) + cache.insert(cache_key, Arc::clone(&computed)); + Ok(computed) } pub fn bind_vm_with_plan(&self, vm: &mut Vm, plan: &HostBindingPlan) -> VmResult<()> { + self.validate_program_capabilities(&vm.program)?; if vm.program.imports != plan.import_signature { return Err(VmError::HostError( "host binding plan does not match vm import signature".to_string(), )); } + if normalize_import_schemas(&vm.program.imports, &vm.program.host_import_schemas)? + != plan.import_schemas + { + return Err(VmError::HostError( + "host binding plan does not match vm catalog schema identity".to_string(), + )); + } + if self.capability_profile.fingerprint() != plan.capability_fingerprint + || self.capability_profile.as_ref() != plan.capability_profile.as_ref() + { + return Err(VmError::HostError( + "host binding plan belongs to a different capability profile".to_string(), + )); + } + if !Arc::ptr_eq(&self.registry_state, &plan.registry_state) { + return Err(VmError::HostError( + "host binding plan belongs to a different registry state".to_string(), + )); + } + if !Arc::ptr_eq( + &self.registry_generation_token, + &plan.registry_generation_token, + ) || self.registry_generation.load(Ordering::Relaxed) != plan.registry_generation + { + return Err(VmError::HostError( + "host binding plan is stale for this registry".to_string(), + )); + } if !vm.host.host_functions.is_empty() || !vm.host.host_function_symbols.is_empty() { return Err(VmError::HostError( "host binding cache requires an unbound vm".to_string(), @@ -454,7 +1052,16 @@ impl HostFunctionRegistry { vm.register_static_non_yielding_args_function(*function); } } + let host_slot = vm.host.host_function_schemas.len() - 1; + if let Some(schema) = vm.host.host_function_schemas.get_mut(host_slot) { + *schema = plan.registry_schemas.get(host_slot).cloned().flatten(); + } } + vm.set_default_host_fallback_enabled(false); + vm.host.allowed_builtin_calls = plan.allowed_builtin_calls.clone(); + vm.host.allow_default_builtin_capabilities = plan.allow_default_builtin_capabilities; + vm.host.allowed_host_function_slots = plan.allowed_host_function_slots.clone(); + vm.host.allow_default_host_capabilities = plan.allow_default_host_capabilities; vm.install_resolved_calls(plan.resolved_calls.clone())?; Ok(()) } @@ -480,6 +1087,9 @@ pub(super) enum HostCallExecOutcome { pub(crate) fn require_non_yielding_host_value(outcome: CallOutcome) -> VmResult { match outcome { CallOutcome::Return(CallReturn::One(value)) => Ok(value), + CallOutcome::Return(CallReturn::Many(_)) => Err(VmError::HostError( + "non-yielding host function returned multiple values".to_string(), + )), CallOutcome::Return(CallReturn::None) => Err(VmError::HostError( "non-yielding host function returned no value".to_string(), )), @@ -495,27 +1105,24 @@ pub(crate) fn require_non_yielding_host_value(outcome: CallOutcome) -> VmResult< } } -pub(crate) fn validate_non_yielding_host_value( - value: Value, - expected: Option, -) -> VmResult { +fn validate_coarse_host_value(value: &Value, expected: ValueType) -> VmResult<()> { let valid = matches!( - (expected, &value), - (None | Some(ValueType::Unknown), _) - | (Some(ValueType::Null), Value::Null) - | (Some(ValueType::Int), Value::Int(_)) - | (Some(ValueType::Float), Value::Float(_)) - | (Some(ValueType::Bool), Value::Bool(_)) - | (Some(ValueType::String), Value::String(_)) - | (Some(ValueType::Bytes), Value::Bytes(_)) - | (Some(ValueType::Array), Value::Array(_)) - | (Some(ValueType::Map), Value::Map(_)) - | (Some(ValueType::Callable), Value::Callable(_)) + (expected, value), + (ValueType::Unknown, _) + | (ValueType::Null, Value::Null) + | (ValueType::Int, Value::Int(_)) + | (ValueType::Float, Value::Float(_)) + | (ValueType::Bool, Value::Bool(_)) + | (ValueType::String, Value::String(_)) + | (ValueType::Bytes, Value::Bytes(_)) + | (ValueType::Array, Value::Array(_)) + | (ValueType::Map, Value::Map(_)) + | (ValueType::Callable, Value::Callable(_)) ); if valid { - return Ok(value); + return Ok(()); } - let expected = match expected.expect("known expected host return type") { + let expected = match expected { ValueType::Unknown => unreachable!(), ValueType::Null => "null", ValueType::Int => "int", @@ -530,22 +1137,324 @@ pub(crate) fn validate_non_yielding_host_value( Err(VmError::TypeMismatch(expected)) } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) fn validate_host_call_return( + values: &CallReturn, + expected: Option, + schema: Option<&HostImportSchema>, + program: &Program, + resources: &ResourceTable, +) -> VmResult<()> { + let value_slice: &[Value] = match values { + CallReturn::None => &[], + CallReturn::One(value) => std::slice::from_ref(value), + CallReturn::Many(values) => values, + }; + + if let Some(schema) = schema { + if matches!(schema.return_type, HostTypeSchema::Unknown) { + return Ok(()); + } + if value_slice.is_empty() && matches!(schema.return_type, HostTypeSchema::Null) { + return Ok(()); + } + if value_slice.len() != 1 { + return Err(VmError::HostError(format!( + "host return cardinality mismatch for '{}': expected one value, got {}", + schema.name, + value_slice.len() + ))); + } + return validate_host_value(&value_slice[0], &schema.return_type, program, resources); + } + + match expected { + None | Some(ValueType::Unknown) => Ok(()), + Some(ValueType::Null) if value_slice.is_empty() => Ok(()), + Some(expected) => { + if value_slice.len() != 1 { + return Err(VmError::HostError(format!( + "host return cardinality mismatch: expected one value, got {}", + value_slice.len() + ))); + } + validate_coarse_host_value(&value_slice[0], expected) + } + } +} + +fn callable_schema_matches( + expected: &HostTypeSchema, + actual: &crate::compiler::TypeSchema, +) -> bool { + use crate::compiler::TypeSchema; + + match (expected, actual) { + (HostTypeSchema::Unknown, _) => true, + (HostTypeSchema::Null, TypeSchema::Null) + | (HostTypeSchema::Int, TypeSchema::Int) + | (HostTypeSchema::Float, TypeSchema::Float) + | (HostTypeSchema::Bool, TypeSchema::Bool) + | (HostTypeSchema::String, TypeSchema::String) + | (HostTypeSchema::Bytes, TypeSchema::Bytes) => true, + (HostTypeSchema::Number, TypeSchema::Int | TypeSchema::Float | TypeSchema::Number) => true, + (HostTypeSchema::Array(expected), TypeSchema::Array(actual)) => { + callable_schema_matches(expected, actual) + } + (HostTypeSchema::Array(expected), TypeSchema::ArrayTuple(items)) => items + .iter() + .all(|item| callable_schema_matches(expected, item)), + (HostTypeSchema::Array(expected), TypeSchema::ArrayTupleRest { prefix, rest }) => { + prefix + .iter() + .all(|item| callable_schema_matches(expected, item)) + && callable_schema_matches(expected, rest) + } + (HostTypeSchema::Map(expected), TypeSchema::Map(actual)) => { + callable_schema_matches(expected, actual) + } + (HostTypeSchema::Map(expected), TypeSchema::Object(fields)) => fields + .values() + .all(|item| callable_schema_matches(expected, item)), + (HostTypeSchema::Optional(expected), TypeSchema::Optional(actual)) => { + callable_schema_matches(expected, actual) + } + ( + HostTypeSchema::Callable { + params: expected_params, + result: expected_result, + }, + TypeSchema::Callable { + params: actual_params, + result: actual_result, + }, + ) => { + if expected_params.is_empty() + && matches!(expected_result.as_ref(), HostTypeSchema::Unknown) + { + return true; + } + expected_params.len() == actual_params.len() + && expected_params + .iter() + .zip(actual_params) + .all(|(expected, actual)| callable_schema_matches(expected, actual)) + && callable_schema_matches(expected_result, actual_result) + } + (HostTypeSchema::Resource(_), _) => false, + _ => false, + } +} + +fn host_callable_schema_matches(expected: &HostTypeSchema, actual: &HostTypeSchema) -> bool { + match (expected, actual) { + (HostTypeSchema::Unknown, _) => true, + (HostTypeSchema::Null, HostTypeSchema::Null) + | (HostTypeSchema::Int, HostTypeSchema::Int) + | (HostTypeSchema::Float, HostTypeSchema::Float) + | (HostTypeSchema::Bool, HostTypeSchema::Bool) + | (HostTypeSchema::String, HostTypeSchema::String) + | (HostTypeSchema::Bytes, HostTypeSchema::Bytes) + | (HostTypeSchema::Number, HostTypeSchema::Number) => true, + (HostTypeSchema::Array(expected), HostTypeSchema::Array(actual)) + | (HostTypeSchema::Map(expected), HostTypeSchema::Map(actual)) + | (HostTypeSchema::Optional(expected), HostTypeSchema::Optional(actual)) => { + host_callable_schema_matches(expected, actual) + } + ( + HostTypeSchema::Callable { + params: expected_params, + result: expected_result, + }, + HostTypeSchema::Callable { + params: actual_params, + result: actual_result, + }, + ) => { + (expected_params.is_empty() + && matches!(expected_result.as_ref(), HostTypeSchema::Unknown)) + || (expected_params.len() == actual_params.len() + && expected_params + .iter() + .zip(actual_params) + .all(|(expected, actual)| host_callable_schema_matches(expected, actual)) + && host_callable_schema_matches(expected_result, actual_result)) + } + (HostTypeSchema::Resource(expected), HostTypeSchema::Resource(actual)) => { + expected == actual + } + _ => false, + } +} + +fn validate_callable_value( + value: &Value, + expected_params: &[HostTypeSchema], + expected_result: &HostTypeSchema, + program: &Program, +) -> VmResult<()> { + let Value::Callable(callable) = value else { + return Err(VmError::TypeMismatch("callable")); + }; + let prototype = program + .callable_prototypes + .get(callable.prototype_id as usize) + .ok_or(VmError::InvalidCallablePrototype(callable.prototype_id))?; + if prototype.kind != callable.kind { + return Err(VmError::TypeMismatch("callable")); + } + let matches = match prototype.schema.as_ref() { + Some(crate::compiler::TypeSchema::Callable { params, result }) => { + prototype.arity as usize == params.len() + && ((expected_params.is_empty() + && matches!(expected_result, HostTypeSchema::Unknown)) + || (expected_params.len() == params.len() + && expected_params + .iter() + .zip(params) + .all(|(expected, actual)| callable_schema_matches(expected, actual)) + && callable_schema_matches(expected_result, result))) + } + Some(_) => false, + None => match prototype.target { + crate::CallableTarget::HostImport(import) => { + let Some(Some(import_schema)) = program.host_import_schemas.get(import as usize) + else { + return Err(VmError::TypeMismatch("callable")); + }; + prototype.arity as usize == import_schema.params.len() + && ((expected_params.is_empty() + && matches!(expected_result, HostTypeSchema::Unknown)) + || (expected_params.len() == import_schema.params.len() + && expected_params.iter().zip(&import_schema.params).all( + |(expected, actual)| { + host_callable_schema_matches(expected, &actual.schema) + }, + ) + && host_callable_schema_matches( + expected_result, + &import_schema.return_type, + ))) + } + crate::CallableTarget::ScriptFunction(_) => false, + }, + }; + if !matches { + return Err(VmError::TypeMismatch("callable")); + } + Ok(()) +} + +fn validate_host_value( + value: &Value, + schema: &HostTypeSchema, + program: &Program, + resources: &ResourceTable, +) -> VmResult<()> { + match schema { + HostTypeSchema::Unknown => Ok(()), + HostTypeSchema::Null => { + if matches!(value, Value::Null) { + Ok(()) + } else { + Err(VmError::TypeMismatch("null")) + } + } + HostTypeSchema::Int => { + if matches!(value, Value::Int(_)) { + Ok(()) + } else { + Err(VmError::TypeMismatch("int")) + } + } + HostTypeSchema::Float => { + if matches!(value, Value::Float(_)) { + Ok(()) + } else { + Err(VmError::TypeMismatch("float")) + } + } + HostTypeSchema::Number => { + if matches!(value, Value::Int(_) | Value::Float(_)) { + Ok(()) + } else { + Err(VmError::TypeMismatch("number")) + } + } + HostTypeSchema::Bool => { + if matches!(value, Value::Bool(_)) { + Ok(()) + } else { + Err(VmError::TypeMismatch("bool")) + } + } + HostTypeSchema::String => { + if matches!(value, Value::String(_)) { + Ok(()) + } else { + Err(VmError::TypeMismatch("string")) + } + } + HostTypeSchema::Bytes => { + if matches!(value, Value::Bytes(_)) { + Ok(()) + } else { + Err(VmError::TypeMismatch("bytes")) + } + } + HostTypeSchema::Array(inner) => { + let Value::Array(values) = value else { + return Err(VmError::TypeMismatch("array")); + }; + for value in values.iter() { + validate_host_value(value, inner, program, resources)?; + } + Ok(()) + } + HostTypeSchema::Map(inner) => { + let Value::Map(values) = value else { + return Err(VmError::TypeMismatch("map")); + }; + for (_, value) in values.iter() { + validate_host_value(value, inner, program, resources)?; + } + Ok(()) + } + HostTypeSchema::Optional(inner) => { + if matches!(value, Value::Null) { + Ok(()) + } else { + validate_host_value(value, inner, program, resources) + } + } + HostTypeSchema::Callable { params, result } => { + validate_callable_value(value, params, result, program) + } + HostTypeSchema::Resource(key) => { + let Value::Int(raw) = value else { + return Err(VmError::TypeMismatch("resource")); + }; + let handle = ResourceHandle::from_raw(*raw as u64) + .map_err(|error| VmError::HostError(error.to_string()))?; + resources + .validate_resource_type_key(handle, key) + .map_err(|error| VmError::HostError(error.to_string())) + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct WaitingHostOp { pub(super) op_id: HostOpId, pub(super) source: WaitingHostOpSource, + pub(super) expected_return_type: Option, + pub(super) expected_return_schema: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum WaitingHostOpSource { HostBridge, - BuiltinIo, - /// A pending builtin SQLite operation. The variant is feature-neutral: - /// the builtin catalog removes `sqlite::*` entries on builds without the - /// adapter, so `builtin_waiting_source` never produces this variant there, - /// and the poll/cancel hooks in `crate::builtins::runtime` are - /// feature-neutral no-ops on such builds. - BuiltinSqlite, + Manual, + ScopedOperation, } struct NoopWake; @@ -566,28 +1475,13 @@ fn builtin_for_binding_name(name: &str) -> Option { BuiltinFunction::from_namespaced_name(name) } -/// Maps a pending builtin to the waiting-op source that polls its concrete -/// driver. IO builtins are driven through the builtin IO mailbox; SQLite -/// builtins through their own completion mailbox. Both poll the shared -/// execution-scope operation registry; only the result-mailbox lookup -/// differs. Feature-neutral: on builds without the SQLite adapter the catalog -/// contains no `sqlite_*` builtin, so the prefix check never matches there. -fn builtin_waiting_source(builtin: BuiltinFunction) -> WaitingHostOpSource { - // The generated `BuiltinFunction::name()` renders the source name with - // `::` collapsed to `_` (e.g. `sqlite_execute`), matching the internal - // catalog name rather than the guest-facing `sqlite::execute`. - if builtin.name().starts_with("sqlite_") { - return WaitingHostOpSource::BuiltinSqlite; - } - WaitingHostOpSource::BuiltinIo -} - impl Vm { pub fn register_function(&mut self, function: Box) -> u16 { let index = self.host.host_functions.len() as u16; self.host .host_functions .push(VmHostFunction::Dynamic(function)); + self.host.host_function_schemas.push(None); self.host.resolved_calls_dirty = true; index } @@ -597,6 +1491,7 @@ impl Vm { self.host .host_functions .push(VmHostFunction::Static(function)); + self.host.host_function_schemas.push(None); self.host.resolved_calls_dirty = true; index } @@ -606,6 +1501,7 @@ impl Vm { self.host .host_functions .push(VmHostFunction::StackDynamic(function)); + self.host.host_function_schemas.push(None); self.host.resolved_calls_dirty = true; index } @@ -615,6 +1511,7 @@ impl Vm { self.host .host_functions .push(VmHostFunction::StackStatic(function)); + self.host.host_function_schemas.push(None); self.host.resolved_calls_dirty = true; index } @@ -624,6 +1521,7 @@ impl Vm { self.host .host_functions .push(VmHostFunction::ArgsDynamic(function)); + self.host.host_function_schemas.push(None); self.host.resolved_calls_dirty = true; index } @@ -633,6 +1531,7 @@ impl Vm { self.host .host_functions .push(VmHostFunction::ArgsStatic(function)); + self.host.host_function_schemas.push(None); self.host.resolved_calls_dirty = true; index } @@ -651,6 +1550,7 @@ impl Vm { self.host .host_functions .push(VmHostFunction::ArgsStaticNonYielding(function)); + self.host.host_function_schemas.push(None); self.host.resolved_calls_dirty = true; index } @@ -858,19 +1758,54 @@ impl Vm { let host_slot = self.host.host_functions.len() as u16; self.host.host_functions.push(function); + self.host.host_function_schemas.push(None); self.host .builtin_overrides .insert(builtin_call_index, host_slot); } - pub fn set_async_bridge(&mut self, bridge: Box) { - self.cancel_waiting_host_op(); + pub fn set_async_bridge(&mut self, bridge: Box) -> VmResult<()> { + if self.host.has_active_bridge_operations() + || self + .instance + .waiting_host_op + .as_ref() + .is_some_and(|waiting| { + matches!( + waiting.source, + crate::vm::host::WaitingHostOpSource::HostBridge + ) + }) + { + return Err(VmError::HostError( + "cannot replace async bridge while an active host operation is present".to_string(), + )); + } + self.cancel_waiting_host_op_with_reason(OperationCancelReason::Requested)?; self.host.async_bridge = Some(bridge); + Ok(()) } - pub fn clear_async_bridge(&mut self) { - self.cancel_waiting_host_op(); + pub fn clear_async_bridge(&mut self) -> VmResult<()> { + if self.host.has_active_bridge_operations() + || self + .instance + .waiting_host_op + .as_ref() + .is_some_and(|waiting| { + matches!( + waiting.source, + crate::vm::host::WaitingHostOpSource::HostBridge + ) + }) + { + return Err(VmError::HostError( + "cannot clear async bridge while an active host operation is present".to_string(), + )); + } + self.cancel_waiting_host_op_with_reason(OperationCancelReason::Requested)?; self.host.async_bridge = None; + Ok(()) } pub fn set_runtime_print_sink(&mut self, sink: F) @@ -884,6 +1819,18 @@ impl Vm { self.host.runtime_print_sink = None; } + /// Configures the per-item event bound applied by `stream::emit` on the + /// invocation stream. + pub fn set_event_limits(&mut self, max_payload_bytes: usize, max_depth: usize) -> VmResult<()> { + let limits = super::runtime::EventLimits::new(max_payload_bytes, max_depth) + .map_err(|error| VmError::HostError(error.to_string()))?; + self.run_ctx.runtime_context = super::runtime::RuntimeContext::with_config( + super::runtime::RuntimeContextConfig::new(limits), + ) + .map_err(|error| VmError::HostError(error.to_string()))?; + Ok(()) + } + pub(crate) fn write_runtime_print(&mut self, rendered: String) -> VmResult<()> { let Some(sink) = self.host.runtime_print_sink.as_mut() else { return Err(VmError::HostError( @@ -894,49 +1841,220 @@ impl Vm { Ok(()) } + /// Enables or disables implicit binding of built-in host functions. + /// + /// Disabling this makes the VM use only explicitly registered host + /// functions. The default remains enabled for backwards compatibility + /// until a registry is bound. + pub fn set_default_host_fallback_enabled(&mut self, enabled: bool) { + self.host.allow_default_host_fallback = enabled; + self.host.resolved_calls_dirty = true; + } + + /// Replaces this VM's standard host-surface composition. + /// + /// The composition is stored on the VM's host runtime and is consulted + /// when implicit host bindings are constructed. Each VM therefore builds + /// and binds its own registry; changing one VM cannot affect another. + pub fn set_standard_composition( + &mut self, + composition: Arc, + ) { + self.host.standard_composition = Some(composition); + self.host.resolved_calls_dirty = true; + } + + /// Returns this VM's standard host-surface composition, if configured. + pub fn standard_composition( + &self, + ) -> Option<&Arc> { + self.host.standard_composition.as_ref() + } + + pub(crate) fn standard_regex_match(&mut self, pattern: &str, text: &str) -> VmResult { + let composition = self.host.standard_composition.clone().ok_or_else(|| { + VmError::HostError("standard surface composition is not installed".to_string()) + })?; + composition.regex_match(self, pattern, text) + } + + pub(crate) fn standard_regex_replace( + &mut self, + pattern: &str, + text: &str, + replacement: &str, + ) -> VmResult { + let composition = self.host.standard_composition.clone().ok_or_else(|| { + VmError::HostError("standard surface composition is not installed".to_string()) + })?; + composition.regex_replace(self, pattern, text, replacement) + } + + /// Whether unbound host imports fall back to the default host functions. + pub fn default_host_fallback_enabled(&self) -> bool { + self.host.allow_default_host_fallback + } + pub fn allocate_host_op_id(&mut self) -> HostOpId { let op_id = self.host.next_host_op_id; self.host.next_host_op_id = self.host.next_host_op_id.wrapping_add(1).max(1); op_id } + /// Registers the adapter-owned completion for one scoped operation. + #[allow(dead_code)] + pub(crate) fn register_scoped_operation_completion( + &mut self, + op_id: OperationId, + completion: impl FnOnce(&mut Vm, OperationOutcome) -> VmResult + Send + 'static, + ) -> VmResult<()> { + if self.host.scoped_operation_completions.contains_key(&op_id) { + return Err(VmError::HostError(format!( + "scoped operation {} already has a completion", + op_id.raw() + ))); + } + self.host + .scoped_operation_completions + .insert(op_id, Box::new(completion)); + Ok(()) + } + + /// Discards an adapter-owned completion when operation startup fails + /// before the VM can enter the waiting state. + #[allow(dead_code)] + pub(crate) fn discard_scoped_operation_completion(&mut self, op_id: OperationId) { + self.host.scoped_operation_completions.remove(&op_id); + } + pub fn waiting_host_op_id(&self) -> Option { - self.instance.waiting_host_op.map(|op| op.op_id) + self.instance.waiting_host_op.as_ref().map(|op| op.op_id) } - pub(super) fn cancel_waiting_host_op(&mut self) { - let Some(waiting) = self.instance.waiting_host_op.take() else { - return; + fn cleanup_waiting_host_op( + &mut self, + waiting: WaitingHostOp, + reason: OperationCancelReason, + ) -> VmResult<()> { + match waiting.source { + WaitingHostOpSource::HostBridge => { + self.host.request_cancel_host_op(waiting.op_id, reason) + } + WaitingHostOpSource::Manual => Ok(()), + WaitingHostOpSource::ScopedOperation => { + let op_id = OperationId::from_raw(waiting.op_id).map_err(|error| { + VmError::ExecutionScope(ExecutionScopeError::Operation(error)) + })?; + self.host.scoped_operation_completions.remove(&op_id); + self.execution_scope() + .abort_operation(op_id, reason) + .map(|_| ()) + .map_err(VmError::ExecutionScope) + } + } + } + + pub(super) fn cancel_waiting_host_op_with_reason( + &mut self, + reason: OperationCancelReason, + ) -> VmResult<()> { + let Some(waiting) = self.instance.waiting_host_op.clone() else { + return Ok(()); }; match waiting.source { WaitingHostOpSource::HostBridge => { - if let Some(bridge) = self.host.async_bridge.as_mut() { - bridge.cancel_op(waiting.op_id); - } + self.host.request_cancel_host_op(waiting.op_id, reason) } - WaitingHostOpSource::BuiltinIo => { - crate::builtins::runtime::cancel_builtin_io_op(self, waiting.op_id); + WaitingHostOpSource::Manual => { + self.instance.waiting_host_op = None; + Ok(()) } - WaitingHostOpSource::BuiltinSqlite => { - crate::builtins::runtime::cancel_builtin_sqlite_op(self, waiting.op_id); + WaitingHostOpSource::ScopedOperation => { + self.instance.waiting_host_op = None; + self.cleanup_waiting_host_op(waiting, reason) } } } + pub(super) fn cancel_waiting_host_op(&mut self) -> VmResult<()> { + self.cancel_waiting_host_op_with_reason(OperationCancelReason::Requested) + } + pub fn complete_host_op( &mut self, op_id: HostOpId, values: impl Into, ) -> VmResult<()> { - self.complete_waiting_host_op(op_id, values.into()) + let waiting = self.instance.waiting_host_op.clone().ok_or_else(|| { + VmError::HostError(format!( + "host op {op_id} completed but vm is not waiting on any op" + )) + })?; + if waiting.op_id != op_id { + return Err(VmError::HostError(format!( + "host op {op_id} completed while vm waits on {}", + waiting.op_id + ))); + } + + let values = values.into(); + let validation_error = validate_host_call_return( + &values, + waiting.expected_return_type, + waiting.expected_return_schema.as_ref(), + &self.program, + self.host.execution_scope.resources(), + ) + .err(); + let terminal = if validation_error.is_some() { + HostAsyncOpTerminal::Failed + } else { + HostAsyncOpTerminal::Completed + }; + let cleanup_result = match waiting.source { + WaitingHostOpSource::HostBridge if self.host.is_bridge_operation_tracked(op_id) => { + self.host.complete_bridge_operation(op_id, terminal) + } + WaitingHostOpSource::HostBridge => Ok(()), + WaitingHostOpSource::Manual => Ok(()), + WaitingHostOpSource::ScopedOperation => { + self.cleanup_waiting_host_op(waiting, OperationCancelReason::Requested) + } + }; + cleanup_result?; + self.instance.waiting_host_op = None; + if let Some(error) = validation_error { + return Err(error); + } + values.push_onto_stack(&mut self.instance.stack); + Ok(()) } pub fn poll_waiting_host_op(&mut self, cx: &mut Context<'_>) -> Poll> { - let Some(waiting) = self.instance.waiting_host_op else { + let Some(waiting) = self.instance.waiting_host_op.clone() else { return Poll::Ready(Ok(())); }; - let poll_result = match waiting.source { + if matches!(waiting.source, WaitingHostOpSource::HostBridge) + && self.host.bridge_cancellation_requested(waiting.op_id) + { + return match self + .host + .poll_bridge_operation_cancellation(waiting.op_id, cx) + { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(())) => { + self.instance.waiting_host_op = None; + Poll::Ready(Ok(())) + } + Poll::Ready(Err(error)) => Poll::Ready(Err(error)), + }; + } + + let bridge_owned = matches!(waiting.source, WaitingHostOpSource::HostBridge) + && self.host.is_bridge_operation_tracked(waiting.op_id); + let submitted = self.host.submitted_host_ops.contains(&waiting.op_id); + let poll_result: Poll> = match waiting.source { WaitingHostOpSource::HostBridge => { let bridge_ptr = match self.host.async_bridge.as_mut() { Some(bridge) => bridge.as_mut() as *mut dyn HostAsyncBridge, @@ -947,30 +2065,128 @@ impl Vm { )))); } }; - - unsafe { (&mut *bridge_ptr).poll_op(waiting.op_id, cx) } - } - WaitingHostOpSource::BuiltinIo => { - crate::builtins::runtime::poll_builtin_io_op(self, waiting.op_id, cx) + // SAFETY: `bridge_ptr` was derived from the unique mutable borrow of + // `self.host.async_bridge` above. The bridge methods receive only the + // pointer's `&mut` reborrow, not `self`, so they cannot move or replace + // the owning `Box`; the pointer is used only for this synchronous call. + unsafe { + if submitted { + (&mut *bridge_ptr).poll_submitted_op(waiting.op_id, cx) + } else { + (&mut *bridge_ptr) + .poll_op(waiting.op_id, cx) + .map(|result| result.map(HostFutureOutput::Return)) + } + } } - WaitingHostOpSource::BuiltinSqlite => { - crate::builtins::runtime::poll_builtin_sqlite_op(self, waiting.op_id, cx) + WaitingHostOpSource::Manual => { + return Poll::Ready(Err(VmError::HostError(format!( + "vm waiting on host op {} without an async bridge", + waiting.op_id + )))); } + WaitingHostOpSource::ScopedOperation => self.poll_scoped_operation(waiting.op_id, cx), }; match poll_result { Poll::Pending => Poll::Pending, - Poll::Ready(Ok(values)) => { - self.complete_waiting_host_op(waiting.op_id, values)?; + Poll::Ready(Ok(output)) => { + let values = match output.finish(self) { + Ok(values) => values, + Err(err) => { + if bridge_owned { + let cleanup = self.host.complete_bridge_operation( + waiting.op_id, + HostAsyncOpTerminal::Failed, + ); + if let Err(cleanup_error) = cleanup { + self.instance.waiting_host_op = None; + return Poll::Ready(Err(cleanup_error)); + } + } + self.instance.waiting_host_op = None; + return Poll::Ready(Err(err)); + } + }; + if bridge_owned { + let validation = validate_host_call_return( + &values, + waiting.expected_return_type, + waiting.expected_return_schema.as_ref(), + &self.program, + self.host.execution_scope.resources(), + ); + if let Err(error) = validation { + let cleanup = self + .host + .complete_bridge_operation(waiting.op_id, HostAsyncOpTerminal::Failed); + self.instance.waiting_host_op = None; + return Poll::Ready(Err(cleanup.err().unwrap_or(error))); + } + if let Err(error) = self + .host + .complete_bridge_operation(waiting.op_id, HostAsyncOpTerminal::Completed) + { + self.instance.waiting_host_op = None; + return Poll::Ready(Err(error)); + } + self.instance.waiting_host_op = None; + values.push_onto_stack(&mut self.instance.stack); + return Poll::Ready(Ok(())); + } + if let Err(error) = self.complete_waiting_host_op(waiting.op_id, values) { + return Poll::Ready(Err(error)); + } Poll::Ready(Ok(())) } Poll::Ready(Err(err)) => { + if matches!(waiting.source, WaitingHostOpSource::HostBridge) { + let cleanup = self + .host + .complete_bridge_operation(waiting.op_id, HostAsyncOpTerminal::Failed); + if let Err(cleanup_error) = cleanup { + self.instance.waiting_host_op = None; + return Poll::Ready(Err(cleanup_error)); + } + } self.instance.waiting_host_op = None; Poll::Ready(Err(err)) } } } + fn poll_scoped_operation( + &mut self, + raw_op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + let op_id = match OperationId::from_raw(raw_op_id) { + Ok(op_id) => op_id, + Err(error) => { + return Poll::Ready(Err(VmError::HostError(format!( + "invalid scoped host operation {raw_op_id}: {error}" + )))); + } + }; + match self.execution_scope().poll_operation(op_id, cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(error)) => { + self.host.scoped_operation_completions.remove(&op_id); + Poll::Ready(Err(VmError::HostError(format!( + "scoped host operation {raw_op_id} failed: {error}" + )))) + } + Poll::Ready(Ok(outcome)) => { + let Some(completion) = self.host.scoped_operation_completions.remove(&op_id) else { + return Poll::Ready(Err(VmError::HostError(format!( + "scoped host operation {raw_op_id} has no completion" + )))); + }; + Poll::Ready(completion(self, outcome).map(HostFutureOutput::Return)) + } + } + } + pub async fn await_waiting_host_op(&mut self) -> VmResult<()> { std::future::poll_fn(|cx| self.poll_waiting_host_op(cx)).await } @@ -1005,6 +2221,12 @@ impl Vm { ) -> VmResult { let argc = argc_u8 as usize; if let Some(builtin) = BuiltinFunction::from_call_index(index) { + if builtin.requires_explicit_host_capability() + && !self.host.allow_default_builtin_capabilities + && !self.host.allowed_builtin_calls.contains(&index) + { + return Err(VmError::UnboundImport(builtin.name().to_string())); + } if !builtin.accepts_arity(argc_u8) { return Err(VmError::InvalidCallArity { import: builtin.name().to_string(), @@ -1032,7 +2254,26 @@ impl Vm { .imports .get(usize::from(index)) .map(|import| import.return_type); + let expected_return_schema = self + .program + .host_import_schemas + .get(usize::from(index)) + .and_then(Clone::clone); let resolved_index = self.resolve_call_target(index, argc_u8)?; + if !self.host.allow_default_host_capabilities + && !self + .host + .allowed_host_function_slots + .contains(&resolved_index) + { + let import_name = self + .program + .imports + .get(usize::from(index)) + .map(|import| import.name.clone()) + .unwrap_or_else(|| format!("host slot {resolved_index}")); + return Err(VmError::UnboundImport(import_name)); + } if let Some(function) = self .host .host_functions @@ -1046,6 +2287,7 @@ impl Vm { function, argc, expected_return_type, + expected_return_schema.as_ref(), ); } if self.bound_host_function_uses_args_slice(resolved_index)? { @@ -1054,11 +2296,24 @@ impl Vm { argc, call_ip, expected_return_type, + expected_return_schema.as_ref(), ) } else if self.bound_host_function_uses_stack_borrow(resolved_index)? { - self.execute_bound_stack_host_function(resolved_index, argc, call_ip) + self.execute_bound_stack_host_function( + resolved_index, + argc, + call_ip, + expected_return_type, + expected_return_schema.as_ref(), + ) } else { - self.execute_bound_host_function_from_stack(resolved_index, argc, call_ip) + self.execute_bound_host_function_from_stack( + resolved_index, + argc, + call_ip, + expected_return_type, + expected_return_schema.as_ref(), + ) } } @@ -1079,12 +2334,32 @@ impl Vm { )) })?; let argc = argc_u8 as usize; + let expected_return_type = BuiltinFunction::from_call_index(builtin_call_index) + .map(|builtin| builtin.static_return_type()); if self.bound_host_function_uses_args_slice(resolved_index)? { - self.execute_bound_args_host_function(resolved_index, argc, call_ip, None) + self.execute_bound_args_host_function( + resolved_index, + argc, + call_ip, + expected_return_type, + None, + ) } else if self.bound_host_function_uses_stack_borrow(resolved_index)? { - self.execute_bound_stack_host_function(resolved_index, argc, call_ip) + self.execute_bound_stack_host_function( + resolved_index, + argc, + call_ip, + expected_return_type, + None, + ) } else { - self.execute_bound_host_function_from_stack(resolved_index, argc, call_ip) + self.execute_bound_host_function_from_stack( + resolved_index, + argc, + call_ip, + expected_return_type, + None, + ) } } @@ -1100,31 +2375,57 @@ impl Vm { .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - // Builtin dispatch reads arguments from the current stack tail while mutating the VM. - // The builtin runtime must not mutate `self.instance.stack` until this borrowed slice is consumed. + let composition = self.host.standard_composition.clone().ok_or_else(|| { + VmError::HostError("standard surface composition is not installed".to_string()) + })?; + // Standard dispatch reads arguments from the current stack tail while mutating the VM. + // The composition must not mutate `self.instance.stack` until this borrowed slice is consumed. let outcome = unsafe { let args = std::slice::from_raw_parts_mut( self.instance.stack.as_mut_ptr().add(arg_start), argc, ); - crate::builtins::runtime::execute_builtin_call(self, builtin, args) + composition.execute_builtin_call(self, builtin, args) }?; match outcome { - crate::builtins::runtime::BuiltinCallOutcome::Return(values) => { + CallOutcome::Return(values) => { self.instance.stack.truncate(arg_start); values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) } - crate::builtins::runtime::BuiltinCallOutcome::Halt => { + CallOutcome::Halt => { self.instance.stack.truncate(arg_start); Ok(HostCallExecOutcome::Halted) } - crate::builtins::runtime::BuiltinCallOutcome::Pending(op_id) => { + CallOutcome::Yield => { + self.instance.stack.truncate(arg_start); + Ok(HostCallExecOutcome::Yielded) + } + CallOutcome::Pending(op_id) => { self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - let source = builtin_waiting_source(builtin); - self.set_waiting_host_op(op_id, source)?; + let expected_return_type = Some(builtin.static_return_type()); + if self.host.submitted_host_ops.contains(&op_id) { + if let Err(error) = self.set_waiting_host_op_with_return( + op_id, + WaitingHostOpSource::HostBridge, + expected_return_type, + None, + ) { + let _ = self + .host + .request_cancel_host_op(op_id, OperationCancelReason::Requested); + return Err(error); + } + } else { + self.set_waiting_host_op_with_return( + op_id, + WaitingHostOpSource::ScopedOperation, + expected_return_type, + None, + )?; + } self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } @@ -1181,18 +2482,18 @@ impl Vm { }, BuiltinFunction::StringContains => match (lhs, rhs, args) { (ValueType::String, ValueType::String, [text, needle]) => { - Self::fast_path_string_contains_result(text, needle) + self.fast_path_string_contains_result(text, needle) } _ => None, }, BuiltinFunction::StringReplaceLiteral => match (lhs, rhs, args) { (ValueType::String, ValueType::String, [text, needle, replacement]) => { - Self::fast_path_string_replace_literal_result(text, needle, replacement) + self.fast_path_string_replace_literal_result(text, needle, replacement) } _ => None, }, BuiltinFunction::StringLowerAscii => match (lhs, args) { - (ValueType::String, [text]) => Self::fast_path_string_lower_ascii_result(text), + (ValueType::String, [text]) => self.fast_path_string_lower_ascii_result(text), _ => None, }, BuiltinFunction::BytesFromArrayU8 => match (lhs, args) { @@ -1262,19 +2563,19 @@ impl Vm { } } - fn fast_path_string_contains_result(text: &Value, needle: &Value) -> Option { + fn fast_path_string_contains_result(&self, text: &Value, needle: &Value) -> Option { let (Value::String(text), Value::String(needle)) = (text, needle) else { return None; }; - Some(Value::Bool( - crate::builtins::runtime::core::builtin_string_contains_impl( - text.as_str(), - needle.as_str(), - ), - )) + self.host + .standard_composition + .as_ref()? + .string_contains(text.as_str(), needle.as_str()) + .map(Value::Bool) } fn fast_path_string_replace_literal_result( + &self, text: &Value, needle: &Value, replacement: &Value, @@ -1284,22 +2585,22 @@ impl Vm { else { return None; }; - Some(Value::string( - crate::builtins::runtime::core::builtin_string_replace_literal_impl( - text.as_str(), - needle.as_str(), - replacement.as_str(), - ), - )) + self.host + .standard_composition + .as_ref()? + .string_replace_literal(text.as_str(), needle.as_str(), replacement.as_str()) + .map(Value::string) } - fn fast_path_string_lower_ascii_result(text: &Value) -> Option { + fn fast_path_string_lower_ascii_result(&self, text: &Value) -> Option { let Value::String(text) = text else { return None; }; - Some(Value::string( - crate::builtins::runtime::core::builtin_string_lower_ascii_impl(text.as_str()), - )) + self.host + .standard_composition + .as_ref()? + .string_lower_ascii(text.as_str()) + .map(Value::string) } fn fast_path_get_result(container: &Value, key: &Value) -> VmResult> { @@ -1506,6 +2807,8 @@ impl Vm { resolved_index: u16, argc: usize, call_ip: usize, + expected_return_type: Option, + expected_return_schema: Option<&HostImportSchema>, ) -> VmResult { let arg_start = self .instance @@ -1547,6 +2850,18 @@ impl Vm { match outcome { CallOutcome::Return(values) => { + if let Err(error) = validate_host_call_return( + &values, + expected_return_type, + expected_return_schema, + &self.program, + self.host.execution_scope.resources(), + ) { + saved_stack.truncate(arg_start); + saved_stack.append(&mut host_stack); + self.instance.stack = saved_stack; + return Err(error); + } saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); values.push_onto_stack(&mut saved_stack); @@ -1570,7 +2885,12 @@ impl Vm { saved_stack.append(&mut host_stack); self.instance.stack = saved_stack; let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, WaitingHostOpSource::HostBridge)?; + self.set_waiting_host_op_with_return( + op_id, + self.host_call_pending_source(), + expected_return_type, + expected_return_schema, + )?; self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } @@ -1609,6 +2929,7 @@ impl Vm { function: StaticHostArgsFunction, argc: usize, expected_return_type: Option, + expected_return_schema: Option<&HostImportSchema>, ) -> VmResult { let arg_start = self .instance @@ -1620,7 +2941,15 @@ impl Vm { let outcome = function(&self.instance.stack[arg_start..]); self.instance.call_depth = self.instance.call_depth.saturating_sub(1); let value = require_non_yielding_host_value(outcome?)?; - let value = validate_non_yielding_host_value(value, expected_return_type)?; + let returned = CallReturn::one(value); + validate_host_call_return( + &returned, + expected_return_type, + expected_return_schema, + &self.program, + self.host.execution_scope.resources(), + )?; + let value = require_non_yielding_host_value(CallOutcome::Return(returned))?; self.instance.stack.truncate(arg_start); self.instance.stack.push(value); Ok(HostCallExecOutcome::Returned) @@ -1632,6 +2961,7 @@ impl Vm { argc: usize, call_ip: usize, expected_return_type: Option, + expected_return_schema: Option<&HostImportSchema>, ) -> VmResult { let arg_start = self .instance @@ -1662,7 +2992,15 @@ impl Vm { let outcome = outcome?; if non_yielding { let value = require_non_yielding_host_value(outcome)?; - let value = validate_non_yielding_host_value(value, expected_return_type)?; + let returned = CallReturn::one(value); + validate_host_call_return( + &returned, + expected_return_type, + expected_return_schema, + &self.program, + self.host.execution_scope.resources(), + )?; + let value = require_non_yielding_host_value(CallOutcome::Return(returned))?; self.instance.stack.truncate(arg_start); self.instance.stack.push(value); return Ok(HostCallExecOutcome::Returned); @@ -1670,6 +3008,13 @@ impl Vm { match outcome { CallOutcome::Return(values) => { + validate_host_call_return( + &values, + expected_return_type, + expected_return_schema, + &self.program, + self.host.execution_scope.resources(), + )?; self.instance.stack.truncate(arg_start); values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) @@ -1685,7 +3030,12 @@ impl Vm { CallOutcome::Pending(op_id) => { self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, WaitingHostOpSource::HostBridge)?; + self.set_waiting_host_op_with_return( + op_id, + self.host_call_pending_source(), + expected_return_type, + expected_return_schema, + )?; self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } @@ -1697,6 +3047,8 @@ impl Vm { resolved_index: u16, argc: usize, call_ip: usize, + expected_return_type: Option, + expected_return_schema: Option<&HostImportSchema>, ) -> VmResult { let arg_start = self .instance @@ -1731,6 +3083,13 @@ impl Vm { match outcome { CallOutcome::Return(values) => { + validate_host_call_return( + &values, + expected_return_type, + expected_return_schema, + &self.program, + self.host.execution_scope.resources(), + )?; self.instance.stack.truncate(arg_start); values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) @@ -1746,7 +3105,12 @@ impl Vm { CallOutcome::Pending(op_id) => { self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, WaitingHostOpSource::HostBridge)?; + self.set_waiting_host_op_with_return( + op_id, + self.host_call_pending_source(), + expected_return_type, + expected_return_schema, + )?; self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } @@ -1773,12 +3137,22 @@ impl Vm { Ok(resume_ip) } - pub(super) fn set_waiting_host_op( + fn host_call_pending_source(&self) -> WaitingHostOpSource { + if self.host.async_bridge.is_some() { + WaitingHostOpSource::HostBridge + } else { + WaitingHostOpSource::Manual + } + } + + pub(super) fn set_waiting_host_op_with_return( &mut self, op_id: HostOpId, source: WaitingHostOpSource, + expected_return_type: Option, + expected_return_schema: Option<&HostImportSchema>, ) -> VmResult<()> { - if let Some(active) = self.instance.waiting_host_op + if let Some(active) = self.instance.waiting_host_op.as_ref() && active.op_id != op_id { return Err(VmError::HostError(format!( @@ -1786,7 +3160,16 @@ impl Vm { active.op_id, op_id ))); } - self.instance.waiting_host_op = Some(WaitingHostOp { op_id, source }); + if matches!(source, WaitingHostOpSource::HostBridge) && self.host.async_bridge.is_some() { + self.host.track_bridge_host_op(op_id)?; + } + let expected_return_schema = expected_return_schema.cloned(); + self.instance.waiting_host_op = Some(WaitingHostOp { + op_id, + source, + expected_return_type, + expected_return_schema, + }); Ok(()) } @@ -1795,7 +3178,7 @@ impl Vm { op_id: HostOpId, values: CallReturn, ) -> VmResult<()> { - let waiting = self.instance.waiting_host_op.ok_or_else(|| { + let waiting = self.instance.waiting_host_op.clone().ok_or_else(|| { VmError::HostError(format!( "host op {} completed but vm is not waiting on any op", op_id @@ -1807,6 +3190,16 @@ impl Vm { op_id, waiting.op_id ))); } + if let Err(error) = validate_host_call_return( + &values, + waiting.expected_return_type, + waiting.expected_return_schema.as_ref(), + &self.program, + self.host.execution_scope.resources(), + ) { + self.instance.waiting_host_op = None; + return Err(error); + } self.instance.waiting_host_op = None; values.push_onto_stack(&mut self.instance.stack); Ok(()) @@ -1835,15 +3228,24 @@ impl Vm { return Ok(()); } - if self.host.host_function_symbols.is_empty() && self.host.host_functions.is_empty() { - let import_names = self - .program - .imports + if self.host.allow_default_host_fallback + && self.host.host_function_symbols.is_empty() + && self.host.host_functions.is_empty() + { + let imports = self.program.imports.clone(); + let Some(composition) = self.host.standard_composition.clone() else { + return HostFunctionRegistry::new().bind_vm_cached(self); + }; + let mut registry = composition.build_default_registry()?; + composition.ensure_surfaces(&imports, &mut registry)?; + if imports .iter() - .map(|import| import.name.clone()) - .collect::>(); - for name in import_names { - let _ = crate::builtins::runtime::bind_default_host_function(self, &name); + .all(|import| registry.contains_name(&import.name)) + { + return registry.bind_vm_cached(self); + } + for import in &imports { + let _ = composition.bind_default_name(self, &import.name); } } @@ -1862,7 +3264,10 @@ impl Vm { let bound = if let Some(bound) = self.host.host_function_symbols.get(&import.name).copied() { bound - } else if crate::builtins::runtime::bind_default_host_function(self, &import.name) { + } else if self.host.allow_default_host_fallback + && let Some(composition) = self.host.standard_composition.clone() + && composition.bind_default_name(self, &import.name) + { self.host .host_function_symbols .get(&import.name) diff --git a/src/vm/host_context.rs b/src/vm/host_context.rs new file mode 100644 index 00000000..2b9f686b --- /dev/null +++ b/src/vm/host_context.rs @@ -0,0 +1,617 @@ +//! Generic host boundary: typed per-VM module state and the generic +//! host-agnostic execution-scope SDK. +//! +//! [`HostContext`] is the public, builtin-agnostic surface that a host +//! embedding or an external host extension (a module living outside +//! `src/builtins/**`) uses to register typed, per-VM module state, push typed +//! [`HostResource`]s, start [`HostOperation`]s, and read back resources / +//! operation status. Every scope SDK method delegates to the +//! [`ExecutionScope`] owned by the underlying +//! [`HostRuntime`](super::host_runtime::HostRuntime), so all inserts land in +//! the same live scope and a Closing/Quiescent scope rejects them with a +//! structured [`ExecutionScopeError::ScopeClosing`] (propagated through +//! [`HostContextErrorKind::Scope`]). +//! +//! It never hands out the underlying [`HostRuntime`](super::host_runtime::HostRuntime) +//! and never names a builtin domain module; concrete SQLite / IO / HTTP / SSE +//! remain same-crate builtins, but `src/vm` must not depend on any of their +//! implementation modules or on `rusqlite`. +//! +//! **Boundary contract (enforced by `tests/host_context_arch_tests.rs`):** +//! this module references neither concrete builtin paths nor `rusqlite`. +//! +//! Host module state is owned directly by [`HostRuntime`]: typed, per-VM, and +//! deliberately **not** cleared on +//! [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse) or on execution-scope +//! close. Registered state therefore survives invocation resets — and scope +//! recycling — for the lifetime of the VM. +#![allow(clippy::result_large_err)] + +use std::any::Any; +use std::fmt; +use std::task::{Context, Poll}; + +use crate::host_api::ResourceTypeKey; + +use super::Vm; +use super::execution_scope::{ExecutionScope, ExecutionScopeError, ScopeState}; +use super::host_runtime::HostRuntime; +use super::operation::{ + OperationCancelReason, OperationError, OperationId, OperationOutcome, OperationSpec, + OperationStatus, +}; +use super::resource::{ + CloseProgress, HostResource, Resource, ResourceCloseReason, ResourceError, ResourceHandle, + ResourceMut, ResourceRef, ResourceTable, +}; + +/// Marker bound for a typed chunk of per-VM host module state. +/// +/// A host extension implements this for exactly one concrete `State` type and +/// registers it through [`HostContext::set_module_state`]. State is typed at +/// compile time (keyed by [`TypeId`]) and is per-`Vm`; it is intentionally not +/// cleared by [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse) or by +/// execution-scope close, so policy / extension configuration survives across +/// invocation resets. +pub trait HostModule: Any + Send + 'static {} + +/// Blanket implementation so any `Send` value can be registered as typed +/// per-VM module state; the trait remains a documentation/constraint marker. +impl HostModule for T {} + +/// Structured failure kind carried by [`HostContextError`]. +/// +/// The generic boundary preserves the underlying structured error instead of +/// flattening it into a message, so callers can match machine-readably (e.g. +/// a rejected insert while the scope is Closing surfaces as +/// [`Self::Scope`]`(ExecutionScopeError::ScopeClosing)`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostContextErrorKind { + /// A plain boundary failure carrying only namespace + message. + Generic, + /// A structured failure from the execution scope (write / lifecycle + /// path: insert rejection while Closing, shutdown sequencing). + Scope(ExecutionScopeError), + /// A structured failure from the resource layer (typed borrow / handle + /// recovery). + Resource(ResourceError), + /// A structured failure from the operation layer (status query). + Operation(OperationError), +} + +/// Error surfaced by the generic host boundary. +/// +/// Carries a stable, non-domain `namespace` plus a human-readable message so +/// host-agnostic failures can be surfaced without referencing any builtin +/// domain type, and a structured [`HostContextErrorKind`] so generic +/// lifecycle violations stay machine-matchable. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostContextError { + namespace: &'static str, + message: String, + kind: HostContextErrorKind, +} + +impl HostContextError { + /// Builds a boundary error with a stable (non-domain) namespace. + pub fn new(namespace: &'static str, message: impl Into) -> Self { + Self { + namespace, + message: message.into(), + kind: HostContextErrorKind::Generic, + } + } + + /// Builds a boundary error from a structured execution-scope failure. + fn from_scope(error: ExecutionScopeError) -> Self { + let message = error.to_string(); + Self { + namespace: "host::scope", + message, + kind: HostContextErrorKind::Scope(error), + } + } + + /// Builds a boundary error from a structured resource-layer failure. + fn from_resource(error: ResourceError) -> Self { + let message = error.to_string(); + Self { + namespace: "host::resource", + message, + kind: HostContextErrorKind::Resource(error), + } + } + + /// Builds a boundary error from a structured operation-layer failure. + fn from_operation(error: OperationError) -> Self { + let message = error.to_string(); + Self { + namespace: "host::operation", + message, + kind: HostContextErrorKind::Operation(error), + } + } + + /// The stable non-domain namespace of this error (e.g. `"host::module"`). + pub fn namespace(&self) -> &'static str { + self.namespace + } + + /// The human readable error message. + pub fn message(&self) -> &str { + &self.message + } + + /// The structured failure kind of this error. + pub fn kind(&self) -> &HostContextErrorKind { + &self.kind + } +} + +impl fmt::Display for HostContextError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.namespace, self.message) + } +} + +impl std::error::Error for HostContextError {} + +/// Result type used by the generic host boundary. +pub type HostContextResult = Result; + +/// The public, generic host boundary for one [`Vm`](super::Vm). +/// +/// Obtained from [`Vm::host_context`](super::Vm::host_context). It never leaks +/// the underlying [`HostRuntime`] and never references a builtin domain module, +/// so external host extensions can register typed per-VM state and drive the +/// generic execution scope through a stable public surface. +pub struct HostContext<'a> { + vm: &'a mut Vm, +} + +impl<'a> HostContext<'a> { + pub(crate) fn new(vm: &'a mut Vm) -> Self { + Self { vm } + } + + /// Registers typed per-VM module state, replacing any earlier value of the + /// same type. + /// + /// Returns `true` when a previously registered value of the same type was + /// replaced, and `false` when this value was freshly registered. + pub fn set_module_state(&mut self, state: M) -> bool { + self.vm.host.set_module_state(state) + } + + /// Borrows the registered typed module state, if any. + pub fn module_state(&self) -> Option<&M> { + self.vm.host.get_module_state() + } + + /// Borrows the registered typed module state mutably, if any. + pub fn module_state_mut(&mut self) -> Option<&mut M> { + self.vm.host.get_module_state_mut() + } + + /// Removes and returns the registered typed module state, if any. + pub fn take_module_state(&mut self) -> Option { + self.vm.host.remove_module_state() + } + + /// Returns `true` when no module state is currently registered. + pub fn is_module_state_empty(&self) -> bool { + self.vm.host.is_module_state_empty() + } + + // ---- generic execution-scope SDK --------------------------------------- + + /// Read-only access to the execution scope owned by this VM's host + /// runtime (observe lifecycle state, resource/operation counts, typed + /// borrows and status). + /// + /// The scope is never handed out mutably through the generic boundary: all + /// mutations flow through the guarded SDK methods below. + pub fn execution_scope(&self) -> &ExecutionScope { + &self.vm.host.execution_scope + } + + /// The current lifecycle phase of this VM's execution scope. + /// + /// (The typed generic sibling [`Self::scope_state`] borrows `T`-typed + /// scope-local state; the lifecycle phase itself is also available through + /// [`Self::execution_scope`]`.state()`.) + pub fn scope_phase(&self) -> ScopeState { + self.vm.host.execution_scope.state() + } + + // ---- typed scope-state arena -------------------------------------------- + + /// Lazily declares `T`-typed scope-local state and returns a mutable + /// handle to it, creating it with `init` on first access while the scope + /// is Active. + /// + /// Scope state lives in the execution-scope arena and is destroyed by + /// [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse); persistent module + /// state (see [`Self::set_module_state`]) survives. A Closing/Quiescent + /// scope rejects the insert with a structured + /// [`HostContextErrorKind::Scope`]`(`[`ExecutionScopeError::ScopeClosing`]`)`. + pub fn scope_state_or_insert_with T>( + &mut self, + init: F, + ) -> HostContextResult<&mut T> { + self.vm + .host + .execution_scope + .scope_state_or_insert_with(init) + .map_err(HostContextError::from_scope) + } + + /// Borrows the `T`-typed scope-local state, if present. + /// + /// Returns `None` after the terminal close cleared the arena (and for a + /// type that was never inserted). This is the typed generic sibling of + /// [`Self::scope_state`], which reports the lifecycle phase. + pub fn scope_state(&self) -> Option<&T> { + self.vm.host.execution_scope.scope_state::() + } + + /// Mutably borrows the `T`-typed scope-local state, if present. + pub fn scope_state_mut(&mut self) -> Option<&mut T> { + self.vm.host.execution_scope.scope_state_mut::() + } + + /// Removes and returns the `T`-typed scope-local state, if present. + pub fn take_scope_state(&mut self) -> Option { + self.vm.host.execution_scope.take_scope_state::() + } + + /// Whether the execution scope is still accepting resource / operation + /// inserts. + pub fn is_scope_active(&self) -> bool { + self.vm.host.execution_scope.is_active() + } + + /// Whether the execution scope reached terminal quiescence. + pub fn is_scope_quiescent(&self) -> bool { + self.vm.host.execution_scope.is_quiescent() + } + + /// Number of live resources in the current execution scope. + pub fn resource_count(&self) -> usize { + self.vm.host.execution_scope.resources().len() + } + + /// Number of occupied operation slots in the current execution scope. + pub fn operation_count(&self) -> usize { + self.vm.host.execution_scope.operations().len() + } + + /// Inserts a typed [`HostResource`] into the current execution scope, + /// returning its typed capability token. + /// + /// A Closing/Quiescent scope rejects the insert with a structured + /// [`HostContextErrorKind::Scope`]`(`[`ExecutionScopeError::ScopeClosing`]`)`. + pub fn push_resource(&mut self, value: T) -> HostContextResult> { + self.vm + .host + .execution_scope + .push_resource(value) + .map_err(HostContextError::from_scope) + } + + /// Alias for [`Self::push_resource`], matching the public extension SDK + /// naming for inserting a typed [`HostResource`] into the current scope. + pub fn insert_resource(&mut self, value: T) -> HostContextResult> { + self.push_resource(value) + } + + /// Starts a host operation in the current execution scope from a full + /// generic [`OperationSpec`] (concrete [`HostOperation`] driver, optional + /// deadline, optional cleanup). + /// + /// External operations must produce concrete [`HostOperation`] drivers: + /// the scope and its registry own poll/cancel, so a driver is the only + /// thing the extension supplies. There is deliberately no second registry + /// and no adapter-specific generic helper on this surface. + pub fn start_operation(&mut self, spec: OperationSpec) -> HostContextResult { + self.vm + .host + .execution_scope + .start_operation(spec) + .map_err(HostContextError::from_scope) + } + + /// Cancels one started operation by id, forwarding the reason to its + /// concrete driver. Returns `false` when the operation was already + /// terminal. + pub fn cancel_operation( + &mut self, + id: OperationId, + reason: OperationCancelReason, + ) -> HostContextResult { + self.vm + .host + .execution_scope + .cancel_operation(id, reason) + .map_err(HostContextError::from_scope) + } + + /// Marks an operation completed without polling. The terminal slot remains + /// occupied until [`take_operation_outcome`](Self::take_operation_outcome). + pub fn complete_operation(&mut self, id: OperationId) -> HostContextResult { + self.vm + .host + .execution_scope + .complete_operation(id) + .map_err(HostContextError::from_scope) + } + + /// Consumes one terminal outcome and releases its slot for generation + /// reuse. + pub fn take_operation_outcome( + &mut self, + id: OperationId, + ) -> HostContextResult { + self.vm + .host + .execution_scope + .take_operation_outcome(id) + .map_err(HostContextError::from_scope) + } + + /// Drives one operation to terminal, polling its concrete driver. + pub fn poll_operation( + &mut self, + id: OperationId, + cx: &mut Context<'_>, + ) -> Poll> { + match self.vm.host.execution_scope.poll_operation(id, cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(result) => Poll::Ready(result.map_err(HostContextError::from_scope)), + } + } + + /// Aborts a started operation after a later handoff step fails. The driver + /// is cancelled at most once, the occupied registry slot is released, and + /// the id becomes stale as one atomic scope lifecycle action. + pub fn abort_operation( + &mut self, + id: OperationId, + reason: OperationCancelReason, + ) -> HostContextResult { + self.vm + .host + .execution_scope + .abort_operation(id, reason) + .map_err(HostContextError::from_scope) + } + + /// Reads the status of one operation. + pub fn operation_status(&self, id: OperationId) -> HostContextResult { + self.vm + .host + .execution_scope + .operations() + .status(id) + .map_err(HostContextError::from_operation) + } + + /// Closes one resource in the current execution scope via the generic + /// table contract. A `Pending` close is driven to completion by the usual + /// scope poll machinery. + pub fn close_resource( + &mut self, + handle: ResourceHandle, + reason: ResourceCloseReason, + ) -> HostContextResult { + self.vm + .host + .execution_scope + .close_resource::(handle, reason) + .map_err(HostContextError::from_scope) + } + + /// Immutably borrows a live resource for the duration of a host call. + /// + /// The token is re-validated against the current scope (arena, slot + /// generation, `TypeId`, open state); a stale / wrong-type / foreign-scope + /// token fails with a structured resource-layer error. + pub fn resource( + &self, + token: &Resource, + ) -> HostContextResult> { + self.vm + .host + .execution_scope + .resources() + .get(token) + .map_err(HostContextError::from_resource) + } + + /// Validates the concrete resource declaration against a catalog key + /// before touching a resource handle. + pub fn validate_resource_type_key( + &self, + expected: &ResourceTypeKey, + ) -> HostContextResult<()> { + ResourceTable::validate_concrete_resource_type_key::(expected) + .map_err(HostContextError::from_resource) + } + + /// Recovers a typed token only after validating both the concrete + /// declaration key and the live handle's key. + pub fn typed_resource_with_key( + &self, + handle: ResourceHandle, + expected: &ResourceTypeKey, + ) -> HostContextResult> { + self.validate_resource_type_key::(expected)?; + self.vm + .host + .execution_scope + .resources() + .validate_resource_type_key(handle, expected) + .map_err(HostContextError::from_resource)?; + self.typed_resource::(handle) + } + + /// Borrows a resource after exact catalog-key validation. + pub fn borrow_resource_with_key( + &self, + handle: ResourceHandle, + expected: &ResourceTypeKey, + ) -> HostContextResult> { + let token = self.typed_resource_with_key::(handle, expected)?; + self.resource(&token) + } + + /// Mutably borrows a resource after exact catalog-key validation. + pub fn borrow_resource_mut_with_key( + &mut self, + handle: ResourceHandle, + expected: &ResourceTypeKey, + ) -> HostContextResult> { + self.validate_resource_type_key::(expected)?; + self.vm + .host + .execution_scope + .resources() + .validate_resource_type_key(handle, expected) + .map_err(HostContextError::from_resource)?; + self.borrow_resource_mut::(handle) + } + + /// Takes a resource after exact catalog-key validation. A mismatch is + /// returned before the table removes or mutates the resource. + pub fn take_resource_with_key( + &mut self, + handle: ResourceHandle, + expected: &ResourceTypeKey, + ) -> HostContextResult { + self.validate_resource_type_key::(expected)?; + self.vm + .host + .execution_scope + .resources() + .validate_resource_type_key(handle, expected) + .map_err(HostContextError::from_resource)?; + self.take_resource::(handle) + } + + /// Alias for [`Self::take_resource_with_key`]. + pub fn take_owned_with_key( + &mut self, + handle: ResourceHandle, + expected: &ResourceTypeKey, + ) -> HostContextResult { + self.take_resource_with_key::(handle, expected) + } + + /// Mutably borrows a typed resource for the duration of this synchronous + /// host call. + pub fn resource_mut( + &mut self, + token: &Resource, + ) -> HostContextResult> { + self.vm + .host + .execution_scope + .resources_mut() + .get_mut(token) + .map_err(HostContextError::from_resource) + } + + /// Validates a raw [`ResourceHandle`] against the current scope and + /// recovers a typed token (read-only). + pub fn typed_resource( + &self, + handle: ResourceHandle, + ) -> HostContextResult> { + self.vm + .host + .execution_scope + .resources() + .typed(handle) + .map_err(HostContextError::from_resource) + } + + /// Borrow a raw handle after typed arena/generation validation. + pub fn borrow_resource( + &self, + handle: ResourceHandle, + ) -> HostContextResult> { + let token = self.typed_resource::(handle)?; + self.resource(&token) + } + + /// Mutably borrow a raw handle after typed arena/generation validation. + pub fn borrow_resource_mut( + &mut self, + handle: ResourceHandle, + ) -> HostContextResult> { + let token = self.typed_resource::(handle)?; + self.resource_mut(&token) + } + + /// Takes a raw typed resource out of the current execution scope and + /// transfers the concrete value to the caller. The handle is validated for + /// arena, generation, state, and concrete type before removal. + pub fn take_resource( + &mut self, + handle: ResourceHandle, + ) -> HostContextResult { + self.vm + .host + .execution_scope + .take_resource::(handle) + .map_err(HostContextError::from_scope) + } + + /// Alias for [`Self::take_resource`] matching the TakeOwned terminology in + /// host-function schemas. + pub fn take_owned(&mut self, handle: ResourceHandle) -> HostContextResult { + self.take_resource::(handle) + } + + /// Begins closing the resource through the generic table contract. + /// + /// This is the generic "close one resource" adapter (host-agnostic): the + /// resource arena/type/generation/live checks and `begin_close` happen + /// before any state mutation, so a rejected close leaves the table + /// untouched. + pub fn begin_close( + &mut self, + handle: ResourceHandle, + reason: ResourceCloseReason, + ) -> HostContextResult { + self.close_resource::(handle, reason) + } +} + +impl HostRuntime { + /// Registers typed per-VM module state, replacing any earlier value of the + /// same type. + pub(crate) fn set_module_state(&mut self, state: M) -> bool { + self.module_state_store.set(state) + } + + /// Borrows the registered typed module state, if any. + pub(crate) fn get_module_state(&self) -> Option<&M> { + self.module_state_store.get() + } + + /// Borrows the registered typed module state mutably, if any. + pub(crate) fn get_module_state_mut(&mut self) -> Option<&mut M> { + self.module_state_store.get_mut() + } + + /// Removes and returns the registered typed module state, if any. + pub(crate) fn remove_module_state(&mut self) -> Option { + self.module_state_store.remove() + } + + /// Returns `true` when no module state is currently registered. + pub(crate) fn is_module_state_empty(&self) -> bool { + self.module_state_store.is_empty() + } +} diff --git a/src/vm/host_extension.rs b/src/vm/host_extension.rs new file mode 100644 index 00000000..d81b3a35 --- /dev/null +++ b/src/vm/host_extension.rs @@ -0,0 +1,756 @@ +//! Public host-extension surface. +//! +//! This module is the controlled extension boundary through which an external +//! host crate installs persistent policy state and registers host functions +//! without accessing any [`HostRuntime`](super::host_runtime::HostRuntime) +//! private field or naming a builtin domain module: +//! +//! - [`HostExtension::install`] installs typed per-VM module state (policy / +//! configuration) through the generic [`HostContext`] module-state store. +//! That store is owned directly by the host runtime: it persists across +//! [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse) and execution-scope +//! close, and it never participates in resource close. +//! - [`HostExtension::register`] registers host functions into a +//! [`HostFunctionRegistry`]. Registration is validated against the +//! extension's [`HostApiCatalog`] via [`catalog_import_schemas`] so the +//! registered function declarations — parameter labels, type schemas and +//! passing modes — match the catalog exactly. The catalog is the +//! authoritative host-side contract: it carries the fingerprint and the +//! resource type keys the host exposes, and the same catalog can be +//! supplied to the compiler so the program's `HostImport`s resolve against +//! it. +//! +//! `src/vm` therefore stays host-agnostic: resource classes, pending +//! operations and module state are supplied by the extension, while the +//! execution scope owns their lifecycle. +//! +//! **Boundary contract:** like [`super::host_context`], this module has no +//! coupling to the builtin runtime modules or any concrete host library. + +use crate::host_api::{ + HostApiCatalog, HostApiFingerprint, HostFunctionSchema, HostParamPassing, HostTypeSchema, +}; +use crate::vm::VmResult; + +pub use super::host_context::HostContext; +pub use super::host_context::HostModule as HostModuleState; +pub use crate::host_api::{HostImportParam, HostImportSchema}; + +/// Public name for the typed per-VM module-state marker used by the external +/// extension surface. +/// +/// `HostModuleState` is the stable alias for `HostModule`: a marker bound on +/// a concrete `State` type (keyed by `TypeId`), registered through +/// [`HostContext::set_module_state`] and borrowed through +/// [`HostContext::module_state`] / [`HostContext::module_state_mut`]. State is +/// per-`Vm`, deliberately survives +/// [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse) and execution-scope +/// close, and never participates in resource close. +/// +/// Registers a host extension against the standard host-function registry and +/// installs its persistent module state. +/// +/// Used directly by embedders; the `register` / `install` lifecycle is split +/// so an extension can also be registered into a caller-supplied (e.g. +/// restricted / capability-granted) [`HostFunctionRegistry`] by calling +/// [`HostExtension::register`] directly and binding it with +/// [`HostFunctionRegistry::bind_vm_cached`]. +pub trait HostExtension: Send + Sync + 'static { + /// Registers this extension's host functions into `registry`. + /// + /// Registration must be validated against the extension's + /// [`HostApiCatalog`] (e.g. [`catalog_import_schemas`] plus the + /// [`validate_catalog_import_schemas`] family); a name-only fallback is + /// not part of this surface. The default registers nothing. + fn register(&self, registry: &mut super::host::HostFunctionRegistry) -> VmResult<()> { + let _ = registry; + Ok(()) + } + + /// Installs this extension's persistent per-VM module state. + /// + /// Typed state installed here (through + /// [`HostContext::set_module_state`]) survives + /// [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse) and scope close and + /// never participates in resource close. + /// + /// **Infallible by design.** The module-state install phase performs no + /// fallible operations (the state store is infallible), so + /// [`Vm::install_extension`](super::Vm::install_extension) can guarantee + /// transactional failure semantics: every fallible step (registration and + /// registry binding) runs *before* this method, and once it runs the VM is + /// fully and consistently installed. Extensions that need a fallible + /// initialization step must perform it in [`Self::register`] instead, so + /// the failure surfaces before any install mutation. The default installs + /// nothing. + fn install(&self, vm: &mut super::Vm) { + let _ = vm; + } + + /// Transactional install: register into a fresh standard registry, bind it + /// to `vm`, then run the infallible install phase. + /// + /// This is the default implementation behind + /// [`Vm::install_extension`](super::Vm::install_extension). Every fallible + /// step (registration and registry binding) runs before [`Self::install`], + /// so a failure leaves the VM unmodified. + fn install_into(&self, vm: &mut super::Vm) -> VmResult<()> { + let mut registry = super::host::HostFunctionRegistry::new(); + self.register(&mut registry)?; + registry.bind_vm_cached(vm)?; + self.install(vm); + Ok(()) + } +} + +/// Converts every catalog-declared overload of `name` into the exact +/// [`HostImportSchema`] the compiler embeds at a call site. +/// +/// The produced schemas carry the declared parameter labels, type schemas, +/// passing modes, return schema and the catalog's own +/// [`HostApiCatalog::fingerprint`](crate::host_api::HostApiCatalog::fingerprint) +/// — exactly the identity stored in a `HostImport`'s schema during codegen +/// when the same catalog is supplied to the compiler. Registering against +/// these schemas therefore satisfies the exact-schema registry lookup with no +/// drift and no raw fingerprint construction on the host side. +pub fn catalog_import_schemas(catalog: &HostApiCatalog, name: &str) -> Vec { + let fingerprint = catalog.fingerprint(); + catalog_import_schemas_with_fingerprint(catalog, name, fingerprint) +} + +fn catalog_import_schemas_with_fingerprint( + catalog: &HostApiCatalog, + name: &str, + fingerprint: HostApiFingerprint, +) -> Vec { + catalog + .functions_named(name) + .into_iter() + .map(|function| HostImportSchema { + name: function.name.clone(), + params: function + .params + .iter() + .map(|param| HostImportParam { + name: param.name.clone(), + schema: param.ty.clone(), + passing: param.passing, + }) + .collect(), + return_type: function.return_type.clone(), + fingerprint, + }) + .collect() +} + +/// Validates the adapter ABI for one required catalog member before registry +/// mutation. The member must exist and match one of the canonical adapter +/// overloads in parameter labels, passing modes, parameter schemas and return +/// schema. Catalog fingerprints are deliberately ignored so custom and +/// combined catalogs remain usable. +pub fn validate_catalog_import_schemas( + catalog: &HostApiCatalog, + contract: &HostApiCatalog, + name: &str, +) -> VmResult> { + validate_catalog_import_schemas_with_fingerprints( + catalog, + contract, + name, + catalog.fingerprint(), + contract.fingerprint(), + ) +} + +/// Validates one adapter member using fingerprints computed once by a +/// registration pass. Adapter contract tables use this to avoid recomputing a +/// catalog fingerprint for every overload/member. +pub fn validate_catalog_import_schemas_with_fingerprints( + catalog: &HostApiCatalog, + contract: &HostApiCatalog, + name: &str, + catalog_fingerprint: HostApiFingerprint, + contract_fingerprint: HostApiFingerprint, +) -> VmResult> { + let expected = catalog_import_schemas_with_fingerprint(contract, name, contract_fingerprint); + let got = catalog_import_schemas_with_fingerprint(catalog, name, catalog_fingerprint); + if got.is_empty() { + return Err(crate::vm::VmError::HostError(format!( + "missing catalog member '{name}' (expected {} overload(s))", + expected.len() + ))); + } + + let compatible = |expected: &HostImportSchema, got: &HostImportSchema| { + expected.params == got.params && expected.return_type == got.return_type + }; + let all_expected_match = expected + .iter() + .all(|expected| got.iter().any(|got| compatible(expected, got))); + let all_got_match = got + .iter() + .all(|got| expected.iter().any(|expected| compatible(expected, got))); + if expected.len() != got.len() || !all_expected_match || !all_got_match { + return Err(crate::vm::VmError::HostError(format!( + "incompatible catalog schema for '{name}': expected {expected:?}, got {got:?}" + ))); + } + Ok(got) +} + +/// A structured failure from catalog-backed host-function registration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CatalogRegistrationError { + /// The catalog has no declaration for the requested function name. + MissingFunction { name: String }, + /// The selected schema was produced from a different catalog. + FingerprintMismatch { + name: String, + expected: HostApiFingerprint, + actual: HostApiFingerprint, + }, + /// The selected declaration has a different number of parameters. + ArityMismatch { + name: String, + expected: usize, + actual: usize, + }, + /// A parameter's semantic type differs from the catalog declaration. + ParameterTypeMismatch { + name: String, + index: usize, + expected: HostTypeSchema, + actual: HostTypeSchema, + }, + /// A parameter's passing mode differs from the catalog declaration. + ParameterPassingMismatch { + name: String, + index: usize, + expected: HostParamPassing, + actual: HostParamPassing, + }, + /// A parameter label differs from the catalog declaration. + ParameterNameMismatch { + name: String, + index: usize, + expected: String, + actual: String, + }, + /// The selected declaration has a different return schema. + ReturnTypeMismatch { + name: String, + expected: HostTypeSchema, + actual: HostTypeSchema, + }, + /// More than one catalog overload matches an arity-only selection. + AmbiguousOverload { + name: String, + arity: usize, + candidates: usize, + }, + /// The selection was not equal to any catalog declaration, but no more + /// specific field-level mismatch could be reported. + SchemaMismatch { + name: String, + selected: Box, + candidates: Box>, + }, + /// The full schema was valid for the catalog but already occupied a + /// registry slot, or would be ambiguous with an existing call shape. + RegistryConflict { name: String, detail: String }, +} + +impl std::fmt::Display for CatalogRegistrationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingFunction { name } => { + write!(f, "catalog declares no function '{name}'") + } + Self::FingerprintMismatch { + name, + expected, + actual, + } => write!( + f, + "catalog fingerprint mismatch for '{name}': expected {expected}, got {actual}" + ), + Self::ArityMismatch { + name, + expected, + actual, + } => write!( + f, + "catalog arity mismatch for '{name}': expected {expected}, got {actual}" + ), + Self::ParameterTypeMismatch { + name, + index, + expected, + actual, + } => write!( + f, + "catalog parameter {index} type mismatch for '{name}': expected {expected:?}, got {actual:?}" + ), + Self::ParameterPassingMismatch { + name, + index, + expected, + actual, + } => write!( + f, + "catalog parameter {index} passing mismatch for '{name}': expected {expected:?}, got {actual:?}" + ), + Self::ParameterNameMismatch { + name, + index, + expected, + actual, + } => write!( + f, + "catalog parameter {index} name mismatch for '{name}': expected '{expected}', got '{actual}'" + ), + Self::ReturnTypeMismatch { + name, + expected, + actual, + } => write!( + f, + "catalog return type mismatch for '{name}': expected {expected:?}, got {actual:?}" + ), + Self::AmbiguousOverload { + name, + arity, + candidates, + } => write!( + f, + "catalog function '{name}' has {candidates} overloads with arity {arity}; select a full schema" + ), + Self::SchemaMismatch { + name, + selected, + candidates, + } => write!( + f, + "selected schema for '{name}' does not match any of {candidates:?}: {selected:?}" + ), + Self::RegistryConflict { name, detail } => { + write!(f, "cannot register catalog function '{name}': {detail}") + } + } + } +} + +impl std::error::Error for CatalogRegistrationError {} + +impl From for crate::vm::VmError { + fn from(error: CatalogRegistrationError) -> Self { + Self::HostError(error.to_string()) + } +} + +/// Selects one full declaration from a catalog for registration. +/// +/// Passing a [`HostImportSchema`] performs field-by-field exact selection. +/// Passing an integer retains the legacy arity-only surface for catalogs with a +/// single matching overload; it returns [`CatalogRegistrationError::AmbiguousOverload`] +/// whenever arity alone cannot identify one declaration. +pub trait CatalogSchemaSelection { + fn select_schema( + &self, + catalog: &HostApiCatalog, + name: &str, + ) -> Result; +} + +fn import_schema_from_function( + catalog: &HostApiCatalog, + function: &HostFunctionSchema, +) -> HostImportSchema { + HostImportSchema { + name: function.name.clone(), + params: function + .params + .iter() + .map(|param| HostImportParam { + name: param.name.clone(), + schema: param.ty.clone(), + passing: param.passing, + }) + .collect(), + return_type: function.return_type.clone(), + fingerprint: catalog.fingerprint(), + } +} + +impl CatalogSchemaSelection for HostFunctionSchema { + fn select_schema( + &self, + catalog: &HostApiCatalog, + name: &str, + ) -> Result { + if self.name != name { + return Err(CatalogRegistrationError::SchemaMismatch { + name: name.to_string(), + selected: Box::new(import_schema_from_function(catalog, self)), + candidates: Box::new(catalog_import_schemas(catalog, name)), + }); + } + import_schema_from_function(catalog, self).select_schema(catalog, name) + } +} + +fn schema_field_mismatch( + name: &str, + selected: &HostImportSchema, + candidate: &HostImportSchema, +) -> Option { + for (index, (expected, actual)) in candidate + .params + .iter() + .zip(selected.params.iter()) + .enumerate() + { + if expected.schema != actual.schema { + return Some(CatalogRegistrationError::ParameterTypeMismatch { + name: name.to_string(), + index, + expected: expected.schema.clone(), + actual: actual.schema.clone(), + }); + } + if expected.passing != actual.passing { + return Some(CatalogRegistrationError::ParameterPassingMismatch { + name: name.to_string(), + index, + expected: expected.passing, + actual: actual.passing, + }); + } + if expected.name != actual.name { + return Some(CatalogRegistrationError::ParameterNameMismatch { + name: name.to_string(), + index, + expected: expected.name.clone(), + actual: actual.name.clone(), + }); + } + } + if candidate.return_type != selected.return_type { + return Some(CatalogRegistrationError::ReturnTypeMismatch { + name: name.to_string(), + expected: candidate.return_type.clone(), + actual: selected.return_type.clone(), + }); + } + None +} + +impl CatalogSchemaSelection for HostImportSchema { + fn select_schema( + &self, + catalog: &HostApiCatalog, + name: &str, + ) -> Result { + let candidates = catalog_import_schemas(catalog, name); + if candidates.is_empty() { + return Err(CatalogRegistrationError::MissingFunction { + name: name.to_string(), + }); + } + let expected_fingerprint = catalog.fingerprint(); + if self.fingerprint != expected_fingerprint { + return Err(CatalogRegistrationError::FingerprintMismatch { + name: name.to_string(), + expected: expected_fingerprint, + actual: self.fingerprint, + }); + } + let exact: Vec<_> = candidates + .iter() + .filter(|candidate| { + candidate.params == self.params && candidate.return_type == self.return_type + }) + .collect(); + if exact.len() == 1 { + return Ok((*exact[0]).clone()); + } + if exact.len() > 1 { + return Err(CatalogRegistrationError::AmbiguousOverload { + name: name.to_string(), + arity: self.params.len(), + candidates: exact.len(), + }); + } + let same_arity: Vec<_> = candidates + .iter() + .filter(|candidate| candidate.params.len() == self.params.len()) + .collect(); + if same_arity.is_empty() { + return Err(CatalogRegistrationError::ArityMismatch { + name: name.to_string(), + expected: candidates + .iter() + .map(|candidate| candidate.params.len()) + .next() + .unwrap_or_default(), + actual: self.params.len(), + }); + } + if same_arity.len() == 1 { + let candidate = same_arity[0]; + return Err( + schema_field_mismatch(name, self, candidate).unwrap_or_else(|| { + CatalogRegistrationError::SchemaMismatch { + name: name.to_string(), + selected: Box::new(self.clone()), + candidates: Box::new(candidates), + } + }), + ); + } + Err(CatalogRegistrationError::SchemaMismatch { + name: name.to_string(), + selected: Box::new(self.clone()), + candidates: Box::new(candidates), + }) + } +} + +impl CatalogSchemaSelection for u8 { + fn select_schema( + &self, + catalog: &HostApiCatalog, + name: &str, + ) -> Result { + usize::from(*self).select_schema(catalog, name) + } +} + +impl CatalogSchemaSelection for usize { + fn select_schema( + &self, + catalog: &HostApiCatalog, + name: &str, + ) -> Result { + let candidates = catalog_import_schemas(catalog, name); + if candidates.is_empty() { + return Err(CatalogRegistrationError::MissingFunction { + name: name.to_string(), + }); + } + let matching: Vec<_> = candidates + .iter() + .filter(|candidate| candidate.params.len() == *self) + .collect(); + match matching.as_slice() { + [] => Err(CatalogRegistrationError::ArityMismatch { + name: name.to_string(), + expected: candidates[0].params.len(), + actual: *self, + }), + [schema] => Ok((*schema).clone()), + _ => Err(CatalogRegistrationError::AmbiguousOverload { + name: name.to_string(), + arity: *self, + candidates: matching.len(), + }), + } + } +} + +impl CatalogSchemaSelection for &T { + fn select_schema( + &self, + catalog: &HostApiCatalog, + name: &str, + ) -> Result { + (*self).select_schema(catalog, name) + } +} + +fn selected_schema( + catalog: &HostApiCatalog, + name: &str, + selection: S, +) -> Result +where + S: CatalogSchemaSelection, +{ + let schema = selection.select_schema(catalog, name)?; + if schema.params.len() > usize::from(u8::MAX) { + return Err(CatalogRegistrationError::ArityMismatch { + name: name.to_string(), + expected: usize::from(u8::MAX), + actual: schema.params.len(), + }); + } + Ok(schema) +} + +/// Registers one catalog-validated host function into `registry`. +/// +/// The selected schema is checked for arity, parameter names/types/passing +/// modes, return type, and overload identity before the registry is mutated. +pub fn register_catalog_function( + registry: &mut super::host::HostFunctionRegistry, + catalog: &HostApiCatalog, + name: &str, + selection: S, + factory: F, +) -> Result<(), CatalogRegistrationError> +where + S: CatalogSchemaSelection, + F: Fn() -> Box + Send + Sync + 'static, +{ + let schema = selected_schema(catalog, name, selection)?; + registry + .register_catalog(schema, factory) + .map(|_| ()) + .map_err(|error| CatalogRegistrationError::RegistryConflict { + name: name.to_string(), + detail: error.to_string(), + }) +} + +/// Static-function counterpart used by external extensions whose handlers are +/// function pointers rather than factory closures. It shares the exact same +/// schema selector and validation path. +pub fn register_catalog_static_function( + registry: &mut super::host::HostFunctionRegistry, + catalog: &HostApiCatalog, + name: &str, + selection: S, + function: super::host::StaticHostFunction, +) -> Result<(), CatalogRegistrationError> +where + S: CatalogSchemaSelection, +{ + let schema = selected_schema(catalog, name, selection)?; + registry + .register_catalog_static(schema, function) + .map(|_| ()) + .map_err(|error| CatalogRegistrationError::RegistryConflict { + name: name.to_string(), + detail: error.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_api::{ + HostApiCatalog, HostApiFingerprint, HostFunctionSchema, HostParamSchema, HostTypeSchema, + }; + use crate::vm::{CallOutcome, HostFunction, HostFunctionRegistry, Value, Vm}; + + struct Noop; + + impl HostFunction for Noop { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(crate::vm::CallReturn::None)) + } + } + + fn catalog_with_function(name: &str) -> HostApiCatalog { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + name, + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::String, + )); + builder.build().expect("test catalog should build") + } + + fn register( + registry: &mut HostFunctionRegistry, + catalog: &HostApiCatalog, + name: &str, + schema: impl CatalogSchemaSelection, + ) -> Result<(), CatalogRegistrationError> { + register_catalog_function(registry, catalog, name, schema, || Box::new(Noop)) + } + + #[test] + fn catalog_registration_accepts_the_selected_full_schema() { + let catalog = catalog_with_function("demo::selected"); + let selected = catalog_import_schemas(&catalog, "demo::selected") + .into_iter() + .next() + .expect("selected schema") + .clone(); + let mut registry = HostFunctionRegistry::empty(); + + register(&mut registry, &catalog, "demo::selected", selected) + .expect("matching schema registers"); + assert!(registry.contains_name("demo::selected")); + } + + #[test] + fn catalog_registration_reports_each_full_schema_mismatch() { + let catalog = catalog_with_function("demo::mismatch"); + let selected = catalog_import_schemas(&catalog, "demo::mismatch") + .into_iter() + .next() + .expect("selected schema") + .clone(); + + let mut wrong_arity = selected.clone(); + wrong_arity.params.clear(); + let mut registry = HostFunctionRegistry::empty(); + assert!(matches!( + register(&mut registry, &catalog, "demo::mismatch", wrong_arity), + Err(CatalogRegistrationError::ArityMismatch { .. }) + )); + + let mut wrong_type = selected.clone(); + wrong_type.params[0].schema = HostTypeSchema::Bool; + assert!(matches!( + register(&mut registry, &catalog, "demo::mismatch", wrong_type), + Err(CatalogRegistrationError::ParameterTypeMismatch { .. }) + )); + + let mut wrong_passing = selected.clone(); + wrong_passing.params[0].passing = HostParamPassing::Borrow; + assert!(matches!( + register(&mut registry, &catalog, "demo::mismatch", wrong_passing), + Err(CatalogRegistrationError::ParameterPassingMismatch { .. }) + )); + + let mut wrong_return = selected.clone(); + wrong_return.return_type = HostTypeSchema::Bool; + assert!(matches!( + register(&mut registry, &catalog, "demo::mismatch", wrong_return), + Err(CatalogRegistrationError::ReturnTypeMismatch { .. }) + )); + + let mut wrong_fingerprint = selected.clone(); + wrong_fingerprint.fingerprint = + HostApiFingerprint::from_wire(wrong_fingerprint.fingerprint.as_u64() ^ 1); + assert!(matches!( + register(&mut registry, &catalog, "demo::mismatch", wrong_fingerprint), + Err(CatalogRegistrationError::FingerprintMismatch { .. }) + )); + } + + #[test] + fn arity_only_registration_rejects_ambiguous_overloads() { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "demo::overloaded", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "demo::overloaded", + vec![HostParamSchema::value("value", HostTypeSchema::String)], + HostTypeSchema::Int, + )); + let catalog = builder.build().expect("distinct overloads should build"); + let mut registry = HostFunctionRegistry::empty(); + + assert!(matches!( + register(&mut registry, &catalog, "demo::overloaded", 1_u8), + Err(CatalogRegistrationError::AmbiguousOverload { .. }) + )); + } +} diff --git a/src/vm/host_runtime.rs b/src/vm/host_runtime.rs index 5f3d67e7..c51759a5 100644 --- a/src/vm/host_runtime.rs +++ b/src/vm/host_runtime.rs @@ -11,30 +11,57 @@ //! This mechanical decomposition groups host-facing ownership and reset/drop //! behavior. The execution scope is the isolated resource/operation owner //! that host code addresses through the generic, host-agnostic -//! [`ExecutionScope`] lifecycle. Persistent adapter policy/configuration -//! lives in the generic [`ModuleStateStore`](super::host_state::ModuleStateStore); +//! [`ExecutionScope`] lifecycle. Host adapters retain their concrete worker +//! state and result mailboxes behind opaque scoped-operation completion hooks; +//! persistent policy lives in the generic module-state store, while //! [`HostRuntime`] stays feature-neutral and owns no concrete adapter state -//! fields (the legacy IO completion mailbox remains on the `Vm` facade until -//! the adapter migrates it onto the scope lifecycle). +//! fields. -use std::any::Any; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::task::{Context, Poll}; -use crate::vm::execution_scope::ExecutionScope; -use crate::vm::host::{HostAsyncBridge, HostOpId, VmHostFunction}; -use crate::vm::host_state::ModuleStateStore; +use crate::host_api::HostImportSchema; +use crate::vm::execution_scope::{ExecutionScope, ExecutionScopeError, ScopeCloseOutcome}; +use crate::vm::host::{ + HostAsyncBridge, HostAsyncOpTerminal, HostOpId, ScopedOperationCompletion, VmHostFunction, +}; +use crate::vm::operation::{OperationCancelReason, OperationId}; +use crate::vm::standard_composition::StandardSurfaceComposition; +use crate::vm::{VmError, VmResult}; /// Embedder-supplied print sink for `print`/`debug` output. pub(crate) type RuntimePrintSink = dyn FnMut(String) + Send; +#[derive(Debug)] +struct BridgeOperationState { + cancellation_reason: Option, + cancellation_error: Option, + terminal: Option, + cleanup_error: Option, +} + +impl BridgeOperationState { + fn new() -> Self { + Self { + cancellation_reason: None, + cancellation_error: None, + terminal: None, + cleanup_error: None, + } + } +} + /// Host-owned capabilities, resources, operations, and subsystem state. /// -/// Thread safety: `HostRuntime` is `!Sync` (host functions and the execution -/// scope are mutable and not shareable) and not shared; one facade owns one -/// host runtime. Clone semantics: not `Clone` — host bindings must not be -/// duplicated across VMs. +/// Thread safety: `HostRuntime` is `!Sync` (host functions, the execution +/// scope — including generic resource/operation registries — are mutable and +/// not shareable) and not shared; one facade owns one host runtime. Clone +/// semantics: not `Clone` — host bindings and scoped operation completions +/// must not be duplicated across VMs. pub(crate) struct HostRuntime { pub(super) host_functions: Vec, + pub(crate) host_function_schemas: Vec>, pub(crate) host_function_symbols: HashMap, pub(crate) builtin_overrides: HashMap, pub(crate) resolved_calls: Vec, @@ -44,17 +71,62 @@ pub(crate) struct HostRuntime { pub(crate) next_host_op_id: HostOpId, /// The isolated execution scope owned by this host runtime. pub(super) execution_scope: ExecutionScope, - /// The single generic per-VM module-state store. + /// True after reset has begun closing the old scope but before the generic + /// close driver has reached quiescence. No usable replacement scope may be + /// admitted while this flag is set. + pub(crate) scope_reset_pending: bool, + /// The terminal failure of the current reset attempt, if any. A failed + /// reset must stay non-reusable and must not silently start another reset + /// or publish a callback registry on a later poll. + scope_reset_error: Option, + /// The one replacement scope allocated for the current reset. It remains + /// unpublished until the old scope in `execution_scope` reaches + /// quiescence. + replacement_execution_scope: Option, + /// The generic per-VM module-state store surfaced to external host + /// extensions through [`HostContext`](super::host_context::HostContext). + /// + /// This is the same generic host-owned policy/configuration storage, but + /// exposed through the public [`HostContext`] boundary. It is typed + /// (keyed by `TypeId`), per-`Vm`, and deliberately **not** cleared on + /// scope reset, so extension policy/configuration survives reset and + /// scope recycling for the lifetime of the VM. + pub(crate) module_state_store: super::host_state::ModuleStateStore, + /// Whether the default builtin capability set is enabled for this VM. /// - /// Persistent adapter policy/configuration (and later external-extension - /// module state) lives here, keyed by `TypeId`, and deliberately survives - /// execution-scope reset for the lifetime of the VM. - pub(crate) module_state_store: ModuleStateStore, + /// A restricted registry (`HostFunctionRegistry::restricted`) binds this + /// to `false`, making privileged builtins require an explicit capability + /// grant before execution. + pub(crate) allow_default_builtin_capabilities: bool, + /// Explicitly allowed builtin call indices (from the bound capability + /// profile), enforced when `allow_default_builtin_capabilities` is off. + pub(crate) allowed_builtin_calls: Vec, + /// Whether the default host capability set is enabled for this VM. + pub(crate) allow_default_host_capabilities: bool, + /// Host-function slots permitted by the bound capability profile, + /// enforced when `allow_default_host_capabilities` is off. + pub(crate) allowed_host_function_slots: Vec, + /// Whether unbound host imports fall back to the default host functions. + pub(crate) allow_default_host_fallback: bool, + /// The standard-surface composition used to construct this VM's default + /// host registry and resolve its unbound imports. + pub(crate) standard_composition: Option>, + /// Ops submitted to the async host bridge (via `Vm::submit_host_future`) + /// that are still pending. These route to the bridge's + /// `poll_submitted_op` instead of a runtime-owned operation driver. + pub(crate) submitted_host_ops: HashSet, + /// Every active bridge-owned operation, including pending host calls that + /// did not originate from `submit_host_future`. Entries remain until the + /// bridge acknowledges a terminal/quiescent state and cleanup succeeds. + bridge_operations: HashMap, + /// Adapter-owned completions for operations driven by the execution scope. + pub(crate) scoped_operation_completions: HashMap, } impl HostRuntime { /// Creates an empty host runtime with no bound functions, no async bridge - /// or print sink, plus a fresh active `ExecutionScope`. + /// or print sink, and no adapter state, plus a fresh active + /// `ExecutionScope`. /// /// The execution-scope construction is fallible only when a process-unique /// identity space (resource arena or operation-registry tag) is exhausted, @@ -64,6 +136,7 @@ impl HostRuntime { pub(crate) fn new() -> Self { Self { host_functions: Vec::new(), + host_function_schemas: Vec::new(), host_function_symbols: HashMap::new(), builtin_overrides: HashMap::new(), resolved_calls: Vec::new(), @@ -73,52 +146,440 @@ impl HostRuntime { next_host_op_id: 1, execution_scope: ExecutionScope::new() .expect("host runtime execution-scope identity space must be available"), - module_state_store: ModuleStateStore::new(), + scope_reset_pending: false, + scope_reset_error: None, + replacement_execution_scope: None, + module_state_store: super::host_state::ModuleStateStore::new(), + allow_default_builtin_capabilities: true, + allowed_builtin_calls: Vec::new(), + allow_default_host_capabilities: true, + allowed_host_function_slots: Vec::new(), + allow_default_host_fallback: true, + standard_composition: None, + submitted_host_ops: HashSet::new(), + bridge_operations: HashMap::new(), + scoped_operation_completions: HashMap::new(), } } - /// Stores host-owned typed module state, replacing any earlier value of - /// the same type. - pub(crate) fn set_module_state(&mut self, state: T) -> bool { - self.module_state_store.set(state) + pub(crate) fn with_standard_composition( + composition: Arc, + ) -> Self { + let mut runtime = Self::new(); + runtime.standard_composition = Some(composition); + runtime } - /// Borrows the registered typed module state, if any. - pub(crate) fn get_module_state(&self) -> Option<&T> { - self.module_state_store.get() + /// Whether the default builtin capability set is enabled. + pub(crate) fn default_builtin_capabilities_enabled(&self) -> bool { + self.allow_default_builtin_capabilities } - /// Borrows the registered typed module state mutably, if any. - #[allow(dead_code)] // used by later host layers (SQLite/capability) in c4/c5 - pub(crate) fn get_module_state_mut(&mut self) -> Option<&mut T> { - self.module_state_store.get_mut() + pub(crate) fn reserve_submitted_host_op(&mut self) -> VmResult { + let op_id = self.next_host_op_id; + if op_id == 0 || op_id == HostOpId::MAX { + return Err(VmError::HostError( + "async host operation id space exhausted".to_string(), + )); + } + if self.submitted_host_ops.contains(&op_id) { + return Err(VmError::HostError(format!( + "submitted host op {op_id} is already tracked" + ))); + } + if self.bridge_operations.contains_key(&op_id) { + return Err(VmError::HostError(format!( + "bridge host op {op_id} is already tracked" + ))); + } + self.submitted_host_ops.insert(op_id); + self.bridge_operations + .insert(op_id, BridgeOperationState::new()); + self.next_host_op_id = op_id + 1; + Ok(op_id) } - /// Removes and returns the registered typed module state, if any. - pub(crate) fn remove_module_state(&mut self) -> Option { - self.module_state_store.remove() + pub(crate) fn rollback_submitted_host_op(&mut self, op_id: HostOpId) { + let removed_submitted = self.submitted_host_ops.remove(&op_id); + let removed_bridge = self.bridge_operations.remove(&op_id).is_some(); + if removed_submitted || removed_bridge { + self.next_host_op_id = op_id; + } } - /// Returns `true` when no module state is currently registered. - #[allow(dead_code)] // used by later host layers (SQLite/capability) in c4/c5 - pub(crate) fn is_module_state_empty(&self) -> bool { - self.module_state_store.is_empty() + pub(crate) fn track_bridge_host_op(&mut self, op_id: HostOpId) -> VmResult<()> { + if self.bridge_operations.contains_key(&op_id) { + return Ok(()); + } + self.bridge_operations + .insert(op_id, BridgeOperationState::new()); + Ok(()) } - /// Replaces the active execution scope with a fresh one. - /// - /// Dropping the old scope runs its generic close sweep, retiring every - /// in-flight operation and closing every resource before the new scope - /// starts. Used by `Vm::reset_for_reuse` so resource/operation teardown - /// goes through the generic scope lifecycle. + pub(crate) fn has_active_bridge_operations(&self) -> bool { + !self.bridge_operations.is_empty() + } + + pub(crate) fn has_pending_bridge_cancellations(&self) -> bool { + self.bridge_operations + .values() + .any(|state| state.cancellation_reason.is_some()) + } + + pub(crate) fn is_bridge_operation_tracked(&self, op_id: HostOpId) -> bool { + self.bridge_operations.contains_key(&op_id) + } + + pub(crate) fn request_cancel_host_op( + &mut self, + op_id: HostOpId, + reason: OperationCancelReason, + ) -> VmResult<()> { + let should_request = { + let state = self.bridge_operations.get_mut(&op_id).ok_or_else(|| { + VmError::HostError(format!("bridge host op {op_id} is not tracked")) + })?; + if let Some(error) = state.cancellation_error.as_ref() { + return Err(VmError::HostError(error.clone())); + } + if let Some(error) = state.cleanup_error.as_ref() { + return Err(VmError::HostError(error.clone())); + } + if state.terminal.is_some() || state.cancellation_reason.is_some() { + false + } else { + state.cancellation_reason = Some(reason); + true + } + }; + if !should_request { + return Ok(()); + } + + let result = match self.async_bridge.as_mut() { + Some(bridge) => bridge.request_cancel_op(op_id, reason), + None => Err(VmError::HostError(format!( + "cannot cancel bridge host op {op_id} without an async bridge" + ))), + }; + if let Err(error) = result { + if let Some(state) = self.bridge_operations.get_mut(&op_id) { + state.cancellation_error = Some(error.to_string()); + } + return Err(error); + } + Ok(()) + } + + pub(crate) fn request_cancel_submitted_host_ops( + &mut self, + reason: OperationCancelReason, + ) -> VmResult<()> { + let mut op_ids = self.bridge_operations.keys().copied().collect::>(); + op_ids.sort_unstable(); + let mut first_error = None; + for op_id in op_ids { + let result = self.request_cancel_host_op(op_id, reason); + if first_error.is_none() { + first_error = result.err(); + } + } + first_error.map_or(Ok(()), Err) + } + + /// Requests cancellation for every bridge operation as a best-effort + /// terminal/drop action. IDs are deliberately retained until a later + /// acknowledgement poll or until this runtime is dropped. + pub(crate) fn cancel_submitted_host_ops(&mut self, reason: OperationCancelReason) { + let _ = self.request_cancel_submitted_host_ops(reason); + } + + fn bridge_operation_error(message: String) -> VmError { + VmError::HostError(message) + } + + pub(crate) fn complete_bridge_operation( + &mut self, + op_id: HostOpId, + terminal: HostAsyncOpTerminal, + ) -> VmResult<()> { + let state_snapshot = self.bridge_operations.get(&op_id).map(|state| { + ( + state.cancellation_reason, + state.cancellation_error.clone(), + state.terminal, + state.cleanup_error.clone(), + ) + }); + let Some((cancellation_reason, cancellation_error, previous_terminal, cleanup_error)) = + state_snapshot + else { + return Err(Self::bridge_operation_error(format!( + "bridge host op {op_id} is not tracked" + ))); + }; + if let Some(error) = cancellation_error.or(cleanup_error) { + return Err(Self::bridge_operation_error(error)); + } + if cancellation_reason.is_some() && terminal != HostAsyncOpTerminal::Cancelled { + return Err(Self::bridge_operation_error(format!( + "bridge host op {op_id} has an outstanding cancellation request" + ))); + } + if let Some(previous_terminal) = previous_terminal { + if previous_terminal != terminal { + return Err(Self::bridge_operation_error(format!( + "bridge host op {op_id} reached conflicting terminal states" + ))); + } + } else if let Some(state) = self.bridge_operations.get_mut(&op_id) { + state.terminal = Some(terminal); + } + + let cleanup_result = match self.async_bridge.as_mut() { + Some(bridge) => bridge.cleanup_op(op_id, terminal), + None => Err(Self::bridge_operation_error(format!( + "cannot clean up bridge host op {op_id} without an async bridge" + ))), + }; + match cleanup_result { + Ok(()) => { + self.bridge_operations.remove(&op_id); + self.submitted_host_ops.remove(&op_id); + Ok(()) + } + Err(error) => { + if let Some(state) = self.bridge_operations.get_mut(&op_id) { + state.cleanup_error = Some(error.to_string()); + } + Err(error) + } + } + } + + pub(crate) fn bridge_cancellation_requested(&self, op_id: HostOpId) -> bool { + self.bridge_operations + .get(&op_id) + .is_some_and(|state| state.cancellation_reason.is_some()) + } + + /// Polls one cancellation acknowledgement. An operation is removed only + /// after `poll_cancel_op` reports quiescence and `cleanup_op` succeeds. + pub(crate) fn poll_bridge_operation_cancellation( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + let state_snapshot = self.bridge_operations.get(&op_id).map(|state| { + ( + state.cancellation_reason, + state.cancellation_error.clone(), + state.terminal, + state.cleanup_error.clone(), + ) + }); + let Some((cancellation_reason, cancellation_error, terminal, cleanup_error)) = + state_snapshot + else { + return Poll::Ready(Ok(())); + }; + if let Some(error) = cancellation_error.or(cleanup_error) { + return Poll::Ready(Err(Self::bridge_operation_error(error))); + } + if terminal.is_some() { + return Poll::Ready(Ok(())); + } + if cancellation_reason.is_none() { + return Poll::Ready(Err(Self::bridge_operation_error(format!( + "bridge host op {op_id} has no cancellation request" + )))); + } + + let poll_result = match self.async_bridge.as_mut() { + Some(bridge) => bridge.poll_cancel_op(op_id, cx), + None => Poll::Ready(Err(Self::bridge_operation_error(format!( + "cannot poll cancellation for bridge host op {op_id} without an async bridge" + )))), + }; + match poll_result { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(())) => { + Poll::Ready(self.complete_bridge_operation(op_id, HostAsyncOpTerminal::Cancelled)) + } + Poll::Ready(Err(error)) => { + if let Some(state) = self.bridge_operations.get_mut(&op_id) { + state.cancellation_error = Some(error.to_string()); + } + Poll::Ready(Err(error)) + } + } + } + + /// Polls all cancellation acknowledgements. Entries without a cancellation + /// request remain pending; reset callers request all of them first. + pub(crate) fn poll_bridge_operations(&mut self, cx: &mut Context<'_>) -> Poll> { + let mut op_ids = self.bridge_operations.keys().copied().collect::>(); + op_ids.sort_unstable(); + let mut first_error = None; + for op_id in op_ids { + if !self.bridge_cancellation_requested(op_id) { + continue; + } + match self.poll_bridge_operation_cancellation(op_id, cx) { + Poll::Pending | Poll::Ready(Ok(())) => {} + Poll::Ready(Err(error)) => { + if first_error.is_none() { + first_error = Some(error); + } + } + } + } + if let Some(error) = first_error { + Poll::Ready(Err(error)) + } else if self.bridge_operations.is_empty() { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + /// Starts generic execution-scope reset and replaces the old scope only + /// after its operation/resource registries report quiescence. Exactly one + /// replacement is allocated at reset start and retained privately while + /// the old scope closes; callers must poll + /// [`poll_reset_execution_scope`](Self::poll_reset_execution_scope) before + /// the VM can be reused. /// - /// Persistent policy/configuration in the `ModuleStateStore` is - /// deliberately **not** touched here; it survives reset. This function - /// contains no adapter name, feature, or concrete `TypeId`: it is wholly - /// feature-neutral. - pub(crate) fn reset_execution_scope(&mut self) { - self.execution_scope = ExecutionScope::new() - .expect("host runtime execution-scope identity space must be available"); + /// Persistent policy/configuration — including the HTTP host + /// configuration and max-in-flight policy, IO policy, SQLite policy, and + /// external-extension module state — lives in the persistent + /// `ModuleStateStore`, which is deliberately **not** touched here. It + /// therefore survives reset (only per-invocation resources, operations, + /// and scope-arena runtime state are retired). This function contains no + /// adapter name, feature, or concrete `TypeId`: it is wholly feature-neutral. + pub(crate) fn reset_execution_scope(&mut self) -> VmResult<()> { + if let Some(error) = self.scope_reset_error.clone() { + return Err(VmError::ExecutionScope(error)); + } + + if !self.scope_reset_pending { + self.request_cancel_submitted_host_ops(OperationCancelReason::VmReset)?; + // Allocate the replacement before publishing or closing anything. + // There is no second allocation after the old scope quiesces. + let replacement = match ExecutionScope::new() { + Ok(scope) => scope, + Err(error) => { + self.fail_reset(error.clone()); + return Err(VmError::ExecutionScope(error)); + } + }; + let close_result = self.execution_scope.is_active().then(|| { + self.execution_scope + .begin_close(crate::vm::resource::ResourceCloseReason::VmReset) + }); + if let Some(Err(error)) = close_result { + self.fail_reset(error.clone()); + return Err(VmError::ExecutionScope(error)); + } + self.scoped_operation_completions.clear(); + self.execution_scope + .cancel_operations_and_wait(crate::vm::operation::OperationCancelReason::VmReset); + self.replacement_execution_scope = Some(replacement); + self.scope_reset_pending = true; + } else { + self.request_cancel_submitted_host_ops(OperationCancelReason::VmReset)?; + self.scoped_operation_completions.clear(); + } + + let waker = std::task::Waker::noop(); + let mut cx = Context::from_waker(waker); + match self.poll_reset_execution_scope(&mut cx) { + Poll::Pending | Poll::Ready(Ok(())) => Ok(()), + Poll::Ready(Err(error)) => Err(error), + } + } + + /// Records a terminal reset failure and closes the old scope without + /// publishing any replacement. The error itself remains authoritative for + /// all later reset/poll attempts. + fn fail_reset(&mut self, error: ExecutionScopeError) { + if self.execution_scope.is_active() { + let _ = self + .execution_scope + .begin_close(crate::vm::resource::ResourceCloseReason::VmReset); + } + self.scoped_operation_completions.clear(); + self.execution_scope + .cancel_operations_and_wait(crate::vm::operation::OperationCancelReason::VmReset); + self.replacement_execution_scope = None; + self.scope_reset_pending = false; + self.scope_reset_error = Some(error); + } + + /// The old scope remains the guarded `execution_scope` while it is closing; + /// the one fresh Active scope is installed only after the old scope reaches + /// `Quiescent`. This is the pool/reuse boundary that prevents stale + /// operation/resource workers from overlapping a replacement VM run. + pub(crate) fn poll_reset_execution_scope( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + if let Some(error) = self.scope_reset_error.clone() { + return Poll::Ready(Err(VmError::ExecutionScope(error))); + } + match self.poll_bridge_operations(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + if !self.scope_reset_pending { + return Poll::Ready(Ok(())); + } + + let result = self.execution_scope.poll_close(cx); + match result { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(error)) => { + self.scope_reset_error = Some(error.clone()); + Poll::Ready(Err(VmError::ExecutionScope(error))) + } + Poll::Ready(Ok(ScopeCloseOutcome::Success)) => { + let replacement = self + .replacement_execution_scope + .take() + .expect("pending scope reset must retain one replacement scope"); + self.execution_scope = replacement; + self.scope_reset_pending = false; + Poll::Ready(Ok(())) + } + Poll::Ready(Ok(outcome @ ScopeCloseOutcome::SuccessWithErrors(_))) => { + // The old scope is quiescent but not clean. Keep it and the + // unpublished replacement in place so a failed reset cannot + // make the VM/pool appear reusable; retain the exact outcome + // for the caller instead of collapsing it into success. + let error = ExecutionScopeError::Close(outcome); + self.scope_reset_error = Some(error.clone()); + Poll::Ready(Err(VmError::ExecutionScope(error))) + } + } + } + + pub(crate) fn scope_reset_error(&self) -> Option<&ExecutionScopeError> { + self.scope_reset_error.as_ref() + } + + pub(crate) fn is_reusable(&self) -> bool { + !self.scope_reset_pending + && self.scope_reset_error.is_none() + && self.execution_scope.is_reusable() + && self.bridge_operations.is_empty() + && self.scoped_operation_completions.is_empty() + } +} + +impl Drop for HostRuntime { + fn drop(&mut self) { + self.cancel_submitted_host_ops(OperationCancelReason::VmDrop); } } diff --git a/src/vm/host_state.rs b/src/vm/host_state.rs index 78333deb..a1ff8f78 100644 --- a/src/vm/host_state.rs +++ b/src/vm/host_state.rs @@ -53,7 +53,6 @@ impl ModuleStateStore { } /// Borrows the registered typed module state mutably, if any. - #[allow(dead_code)] // used by later host layers (SQLite/capability) in c4/c5 pub(crate) fn get_mut(&mut self) -> Option<&mut T> { self.entries .get_mut(&TypeId::of::()) @@ -73,7 +72,6 @@ impl ModuleStateStore { } /// Returns `true` when no module state is currently registered. - #[allow(dead_code)] // used by later host layers (SQLite/capability) in c4/c5 pub(crate) fn is_empty(&self) -> bool { self.entries.is_empty() } diff --git a/src/vm/instance.rs b/src/vm/instance.rs index baecdfb0..02d5842a 100644 --- a/src/vm/instance.rs +++ b/src/vm/instance.rs @@ -19,6 +19,7 @@ use std::sync::{Arc, Weak}; use crate::bytecode::{CallableValue, Program, SharedCaptureCell, Value}; use crate::vm::host::WaitingHostOp; +use crate::vm::invocation::{InvocationPhase, InvocationState}; use crate::vm::map_iter::MapIteratorState; use crate::vm::{DEFAULT_MAX_SCRIPT_CALL_DEPTH, VmYieldReason}; @@ -74,6 +75,7 @@ pub(crate) struct Instance { pub(crate) active_local_base_cache: usize, pub(crate) active_operand_stack_base_cache: usize, pub(crate) call_depth: usize, + pub(crate) run_depth: usize, pub(crate) max_script_call_depth: usize, pub(crate) host_return: Option, pub(crate) queued_callables: VecDeque, @@ -84,6 +86,7 @@ pub(crate) struct Instance { pub(crate) shutdown: bool, pub(super) waiting_host_op: Option, pub(crate) last_yield_reason: Option, + pub(crate) invocation: Option, pub(crate) map_iterators: Vec>>, pub(crate) drop_contract_events_enabled: bool, pub(crate) drop_contract_events: u64, @@ -110,6 +113,7 @@ impl Instance { active_local_base_cache: 0, active_operand_stack_base_cache: 0, call_depth: 0, + run_depth: 0, max_script_call_depth: DEFAULT_MAX_SCRIPT_CALL_DEPTH, host_return: None, queued_callables: VecDeque::new(), @@ -120,6 +124,7 @@ impl Instance { shutdown: false, waiting_host_op: None, last_yield_reason: None, + invocation: None, map_iterators: Vec::new(), drop_contract_events_enabled: false, drop_contract_events: 0, @@ -149,6 +154,7 @@ impl Instance { self.locals.resize(program.local_count, Value::Null); self.initialize_root_callable_bindings(program); self.call_depth = 0; + self.run_depth = 0; self.execution_frames.clear(); self.execution_frames .push(ExecutionFrame::root(program.local_count)); @@ -157,23 +163,121 @@ impl Instance { self.host_return = None; self.queued_callables.clear(); self.completed_callable_results.clear(); - self.owned_callables.clear(); self.draining_queued_callables = false; self.shutdown = false; self.waiting_host_op = None; + self.drop_invocation_state(); + self.invocation = None; self.map_iterators.clear(); self.clear_interpreter_metrics(); } + pub(crate) fn is_reusable(&self, program: &Program) -> bool { + if self.run_depth != 0 + || !self.stack.is_empty() + || !self.capture_cells.is_empty() + || !self.shared_capture_slots.is_empty() + || self.active_local_base_cache != 0 + || self.active_operand_stack_base_cache != 0 + || self.call_depth != 0 + || self.host_return.is_some() + || !self.queued_callables.is_empty() + || !self.completed_callable_results.is_empty() + || self.draining_queued_callables + || self.shutdown + || self.waiting_host_op.is_some() + || self.last_yield_reason.is_some() + || self.map_iterators.iter().flatten().any(Option::is_some) + { + return false; + } + + let reset_frame = self + .execution_frames + .as_slice() + .first() + .is_some_and(|root| { + self.execution_frames.len() == 1 + && root.continuation == FrameContinuation::Halt + && root.operand_stack_base == 0 + && root.local_base == 0 + && root.local_count == program.local_count + && root.prototype_id.is_none() + }); + let halted = self.execution_frames.is_empty(); + if (!reset_frame && !halted) || (reset_frame && self.ip != 0) { + return false; + } + if self.locals.len() != program.local_count { + return false; + } + + let mut root_bindings = HashMap::new(); + for binding in &program.root_callable_bindings { + if binding.local_slot as usize >= program.local_count + || program + .callable_prototypes + .get(binding.prototype_id as usize) + .is_none() + { + continue; + } + root_bindings.insert(binding.local_slot as usize, binding.prototype_id); + } + for (slot, value) in self.locals.iter().enumerate() { + match root_bindings.get(&slot) { + Some(prototype_id) => { + if !matches!(value, Value::Callable(callable) if callable.prototype_id == *prototype_id) + { + return false; + } + } + None if !matches!(value, Value::Null) => return false, + None => {} + } + } + + match self.invocation.as_ref() { + None => true, + Some(state) => { + matches!(state.phase, InvocationPhase::Fused) + && !state.emit_yield_pending + && state.pending_error.is_none() + && state.cancel_reason.is_none() + } + } + } + /// Releases interpreter-owned values with drop-contract accounting. Used by /// the facade's `Drop` (and by `shutdown`). pub(crate) fn drop_cleanup(&mut self) { + self.drop_invocation_state(); self.clear_stack_with_drop_contract(); self.capture_cells.clear(); self.shared_capture_slots.clear(); self.clear_locals_with_drop_contract(); } + /// Drops pending invocation stream values with drop-contract accounting and + /// rewinds the invocation state to a fresh, fused position. + pub(crate) fn drop_invocation_state(&mut self) { + let Some(state) = self.invocation.as_mut() else { + return; + }; + let value = match std::mem::replace(&mut state.phase, InvocationPhase::Fused) { + InvocationPhase::EventPending(value) | InvocationPhase::CompletePending(value) => { + Some(value) + } + _ => None, + }; + state.emit_yield_pending = false; + state.pending_error = None; + state.cancel_reason = None; + if let Some(value) = value { + self.drop_value_with_contract(value); + } + } + pub(crate) fn invalidate_callback_registries(&mut self) { for active in self .callback_registry_flags diff --git a/src/vm/invocation.rs b/src/vm/invocation.rs new file mode 100644 index 00000000..ea098f83 --- /dev/null +++ b/src/vm/invocation.rs @@ -0,0 +1,675 @@ +//! Invocation item stream. +//! +//! One exported callable started with ordinary arguments behaves like +//! `Stream>`: zero or more +//! `Event` items produced by `stream::emit`, then exactly one `Complete` item +//! or one typed error, then a fused end of stream. Polling drives execution; +//! the VM does not produce items while the consumer is not polling, and at most +//! one event item is buffered between polls (natural backpressure). +//! +//! The invocation reuses the existing callable execution state +//! ([`Vm::start_callable`], [`Vm::run`], [`Vm::take_invocation_result`]) and the +//! existing async host bridge; it does not duplicate interpreter or host loops, +//! and it does not add an executor, generator syntax, an event queue, or event +//! persistence policy. Cancellation is a per-invocation typed reason carried +//! on the invocation state and forwarded to outstanding waiting host +//! operations; there is no standalone cancellation-token graph or parallel +//! event subsystem. + +use std::collections::HashSet; +use std::fmt; +use std::sync::Arc; +use std::task::{Context, Poll, Waker}; + +use crate::bytecode::{CallableEnvironment, CallableValue, VmMap}; +use crate::vm::operation::reason::OperationCancelReason; +use crate::vm::runtime::{EventPayload, RuntimeError, RuntimeErrorCode}; +use crate::vm::{CallOutcome, CallReturn, Value, Vm, VmError, VmResult, VmStatus, VmYieldReason}; + +/// One item yielded by an invocation stream. +#[derive(Clone, Debug, PartialEq)] +pub enum InvocationItem { + /// One bounded event produced by `stream::emit(value)`. + Event(Value), + /// The callable's return value; exactly one per invocation. + Complete(Value), +} + +/// Typed terminal failure of an invocation stream. +/// +/// The failure is machine-readable: cancellation keeps its reason, fuel and +/// deadline failures keep their numeric state, and stream::emit validation +/// keeps its structured [`RuntimeError`] instead of being flattened to a +/// string. +#[derive(Debug)] +pub enum InvocationError { + /// The invocation was cancelled with this reason. + Cancelled(OperationCancelReason), + /// The configured fuel budget was exhausted. + OutOfFuel { needed: u64, remaining: u64 }, + /// The configured epoch deadline expired. + DeadlineReached { current: u64, deadline: u64 }, + /// A structured runtime error (for example event payload validation). + Capability(RuntimeError), + /// An embedding host failure without a structured runtime code. + Host { message: String }, + /// A low-level VM failure (script error or invalid frame state). + Vm(VmError), +} + +/// Poll outcome of an invocation stream. +#[derive(Debug)] +pub enum InvocationPoll { + /// The VM is paused (waiting on a host operation or a host-driven yield); + /// drive the outstanding work and poll again. + Pending, + /// One stream item, or `None` after the fused end of stream. + Ready(Option>), +} + +/// Run-scoped state of the single active invocation on a VM. +#[derive(Debug)] +pub(crate) struct InvocationState { + pub(crate) phase: InvocationPhase, + /// True while the VM is yielded at a `stream::emit` call site whose event + /// has already been delivered. The resumed call site re-enters + /// `stream::emit` and consumes this marker instead of emitting a second + /// event for the same call. + pub(crate) emit_yield_pending: bool, + /// A structured runtime error produced by `stream::emit` validation, + /// preserved for the terminal error item without string flattening. + pub(crate) pending_error: Option, + /// A typed cancellation request made through [`Invocation::cancel`], + /// consumed by the poller to produce exactly one `Cancelled` item. + /// Per-invocation: cleared on fusion so it cannot leak into a later + /// invocation started on the same VM. + pub(crate) cancel_reason: Option, + /// Stack and frame position recorded when the invocation started, used to + /// release interpreter state on terminal failure. + pub(crate) stack_base: usize, + pub(crate) frame_count: usize, +} + +#[derive(Debug)] +pub(crate) enum InvocationPhase { + Running, + EventPending(Value), + CompletePending(Value), + ErrorPending(InvocationError), + Fused, +} + +/// One active invocation handle borrowing the VM. +/// +/// Polling drives execution. Dropping a handle that has not fused retires its +/// invocation synchronously and leaves bridge-owned work tracked until its +/// cancellation acknowledgement is observed. The VM must not be reused while +/// that acknowledgement is pending. +pub struct Invocation<'vm> { + vm: &'vm mut Vm, +} + +impl fmt::Debug for Invocation<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Invocation").finish_non_exhaustive() + } +} + +impl Invocation<'_> { + /// Polls the invocation stream. + /// + /// Returns `Ready(Some(Ok(Event(value))))` for each emitted event, + /// `Ready(Some(Ok(Complete(value))))` exactly once for the callable return + /// value, `Ready(Some(Err(error)))` exactly once for a typed terminal + /// failure, and `Ready(None)` on every poll after the stream has fused. + /// `Pending` means the VM is paused on an outstanding host operation or + /// host-driven yield; drive it and poll again. + /// + /// This convenience method uses a no-op waker for synchronous/manual + /// polling. Async callers should use [`Self::poll_next_with_context`] so + /// pending host operations can wake their executor. + pub fn poll_next(&mut self) -> VmResult { + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + self.poll_next_with_context(&mut context) + } + + /// Polls the invocation with the caller's task context. + /// + /// The supplied waker is forwarded to a pending host operation. This is + /// the executor-compatible entry point; it does not spin while an async + /// host bridge is waiting. + pub fn poll_next_with_context( + &mut self, + context: &mut Context<'_>, + ) -> VmResult { + self.vm.poll_invocation_with_context(context) + } + + /// Cancels the active invocation with a typed reason. + /// + /// Outstanding waiting host operations are cancelled. The next poll + /// produces exactly one `Cancelled(reason)` error item, after which the + /// stream is fused. + pub fn cancel(&mut self, reason: OperationCancelReason) -> VmResult<()> { + let state = self + .vm + .instance + .invocation + .as_mut() + .ok_or(VmError::InvalidFrameState( + "no invocation is active on this vm", + ))?; + if matches!(state.phase, InvocationPhase::Fused) { + return Err(VmError::InvalidFrameState( + "the active invocation has already fused", + )); + } + let cancellation_reason = match state.cancel_reason { + Some(first) => first, + None => { + state.cancel_reason = Some(reason); + reason + } + }; + self.vm + .cancel_waiting_host_op_with_reason(cancellation_reason)?; + Ok(()) + } +} + +impl Drop for Invocation<'_> { + fn drop(&mut self) { + let active = self + .vm + .instance + .invocation + .as_ref() + .is_some_and(|state| !matches!(state.phase, InvocationPhase::Fused)); + if active { + self.vm.release_invocation(); + } + } +} + +/// One poll step selected from the current invocation phase. +enum InvocationAction { + Cancelled, + Event, + Complete, + Error, + Fused, + Drive, +} + +const MAX_CALLABLE_OWNERSHIP_NODES: usize = 65_536; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum CallableOwnershipIdentity { + Array(*const Vec), + Map(*const VmMap), + Callable(*const CallableValue), + Environment(*const CallableEnvironment), +} + +impl Vm { + fn validate_invocation_callable_ownership( + &self, + callable: &Value, + args: &[Value], + ) -> VmResult<()> { + if args.len().saturating_add(1) > MAX_CALLABLE_OWNERSHIP_NODES { + return Err(VmError::InvalidCallable); + } + let mut pending = Vec::with_capacity(args.len().saturating_add(1)); + pending.push(callable.clone()); + pending.extend(args.iter().cloned()); + let mut visited = HashSet::new(); + let mut visited_nodes = 0usize; + + let push_pending = |pending: &mut Vec, value: Value| -> VmResult<()> { + if pending.len() >= MAX_CALLABLE_OWNERSHIP_NODES { + return Err(VmError::InvalidCallable); + } + pending.push(value); + Ok(()) + }; + + while let Some(value) = pending.pop() { + visited_nodes = visited_nodes.saturating_add(1); + if visited_nodes > MAX_CALLABLE_OWNERSHIP_NODES { + return Err(VmError::InvalidCallable); + } + + match value { + Value::Array(values) => { + if visited.insert(CallableOwnershipIdentity::Array(Arc::as_ptr(&values))) { + for value in values.iter().cloned() { + push_pending(&mut pending, value)?; + } + } + } + Value::Map(values) => { + if visited.insert(CallableOwnershipIdentity::Map(Arc::as_ptr(&values))) { + for (key, value) in values.iter() { + push_pending(&mut pending, key.clone())?; + push_pending(&mut pending, value.clone())?; + } + } + } + Value::Callable(callable) => { + if !visited.insert(CallableOwnershipIdentity::Callable(Arc::as_ptr(&callable))) + { + continue; + } + if !self.owns_callable(&Value::Callable(Arc::clone(&callable))) { + return Err(VmError::InvalidCallable); + } + let Some(environment) = callable.env.as_ref() else { + continue; + }; + if !visited.insert(CallableOwnershipIdentity::Environment(Arc::as_ptr( + environment, + ))) { + continue; + } + let cells = environment + .cells + .lock() + .map_err(|_| VmError::InvalidCallable)?; + for cell in cells.iter() { + let value = cell.lock().map_err(|_| VmError::InvalidCallable)?.clone(); + push_pending(&mut pending, value)?; + } + } + Value::Null + | Value::Int(_) + | Value::Float(_) + | Value::Bool(_) + | Value::String(_) + | Value::Bytes(_) => {} + } + } + Ok(()) + } + + /// Starts one invocation of an exported callable with ordinary arguments. + /// + /// The VM must be halted (complete the root frame with [`Vm::run`] first), + /// and must not already have an active invocation. A second invocation on + /// the same VM is rejected while one is active. + pub fn start_invocation( + &mut self, + callable: Value, + args: Vec, + ) -> VmResult> { + self.validate_invocation_callable_ownership(&callable, &args)?; + if self.host.has_active_bridge_operations() { + return Err(VmError::HostError( + "bridge host operation is not quiescent".to_string(), + )); + } + self.ensure_scope_ready()?; + if self + .instance + .invocation + .as_ref() + .is_some_and(|state| !matches!(state.phase, InvocationPhase::Fused)) + { + return Err(VmError::InvalidFrameState( + "an invocation is already active on this vm", + )); + } + let stack_base = self.instance.stack.len(); + let frame_count = self.instance.execution_frames.len(); + self.instance.invocation = Some(InvocationState { + phase: InvocationPhase::Running, + emit_yield_pending: false, + pending_error: None, + cancel_reason: None, + stack_base, + frame_count, + }); + + match self.start_callable(callable, &args) { + Ok(VmStatus::Halted) => { + let result = match self.take_invocation_result() { + Some(result) => result, + None => { + let error = self.map_invocation_error(VmError::InvalidFrameState( + "invocation halted without a callable result", + )); + self.release_invocation(); + self.instance + .invocation + .as_mut() + .expect("invocation state") + .phase = InvocationPhase::ErrorPending(error); + return Ok(Invocation { vm: self }); + } + }; + self.instance + .invocation + .as_mut() + .expect("invocation state") + .phase = InvocationPhase::CompletePending(result); + } + Ok(VmStatus::Yielded) => { + // Either `stream::emit` placed one pending event, or the + // embedding must drive a host-owned yield; both are serviced by + // the next poll. + } + Ok(VmStatus::Waiting(_)) => {} + Err(error) => { + let error = self.map_invocation_error(error); + self.release_invocation(); + self.instance + .invocation + .as_mut() + .expect("invocation state") + .phase = InvocationPhase::ErrorPending(error); + } + } + Ok(Invocation { vm: self }) + } + + fn poll_invocation_with_context( + &mut self, + context: &mut Context<'_>, + ) -> VmResult { + loop { + let action = match self.instance.invocation.as_ref() { + Some(state) => { + // Authoritative cancellation supersedes a pending Event or + // Complete: the pending value is discarded (through the + // drop-contract path) and the stream transitions to one + // Cancelled item, then a fused end. + let bridge_cancellation_pending = state.cancel_reason.is_some() + && self + .instance + .waiting_host_op + .as_ref() + .is_some_and(|waiting| { + matches!( + waiting.source, + crate::vm::host::WaitingHostOpSource::HostBridge + ) + }); + if state.cancel_reason.is_some() + && !bridge_cancellation_pending + && matches!( + state.phase, + InvocationPhase::EventPending(_) | InvocationPhase::CompletePending(_) + ) + { + InvocationAction::Cancelled + } else { + match state.phase { + InvocationPhase::EventPending(_) => InvocationAction::Event, + InvocationPhase::CompletePending(_) => InvocationAction::Complete, + InvocationPhase::ErrorPending(_) => InvocationAction::Error, + InvocationPhase::Fused => InvocationAction::Fused, + InvocationPhase::Running => InvocationAction::Drive, + } + } + } + None => return Ok(InvocationPoll::Ready(None)), + }; + match action { + InvocationAction::Cancelled => { + let reason = self + .instance + .invocation + .as_ref() + .and_then(|state| state.cancel_reason) + .expect("a cancelled action requires a cancellation reason"); + let discarded = self.replace_invocation_phase(InvocationPhase::ErrorPending( + InvocationError::Cancelled(reason), + )); + match discarded { + InvocationPhase::EventPending(value) + | InvocationPhase::CompletePending(value) => { + self.drop_value_with_contract(value); + } + _ => unreachable!("the cancelled action matched a pending phase above"), + } + } + InvocationAction::Event => { + let value = match self.replace_invocation_phase(InvocationPhase::Running) { + InvocationPhase::EventPending(value) => value, + _ => unreachable!("phase matched above"), + }; + // `emit_yield_pending` stays set until the resumed call + // site re-enters `stream::emit`. + return Ok(InvocationPoll::Ready(Some(Ok(InvocationItem::Event( + value, + ))))); + } + InvocationAction::Complete => { + let value = match self.replace_invocation_phase(InvocationPhase::Fused) { + InvocationPhase::CompletePending(value) => value, + _ => unreachable!("phase matched above"), + }; + self.release_invocation(); + return Ok(InvocationPoll::Ready(Some(Ok(InvocationItem::Complete( + value, + ))))); + } + InvocationAction::Error => { + let error = match self.replace_invocation_phase(InvocationPhase::Fused) { + InvocationPhase::ErrorPending(error) => error, + _ => unreachable!("phase matched above"), + }; + self.release_invocation(); + return Ok(InvocationPoll::Ready(Some(Err(error)))); + } + InvocationAction::Fused => return Ok(InvocationPoll::Ready(None)), + InvocationAction::Drive => { + let result = self.drive_invocation(context); + match result { + DriveOutcome::Continue => {} + DriveOutcome::Pending => return Ok(InvocationPoll::Pending), + DriveOutcome::Error(error) => { + self.release_invocation(); + self.instance + .invocation + .as_mut() + .expect("invocation state") + .phase = InvocationPhase::ErrorPending(error); + } + } + } + } + } + } + + /// Runs the low-level pump once and folds the outcome into the invocation + /// phase. `Vm::run` itself is unchanged. + fn drive_invocation(&mut self, context: &mut Context<'_>) -> DriveOutcome { + if let Some(reason) = self + .instance + .invocation + .as_ref() + .and_then(|state| state.cancel_reason) + { + if self + .instance + .waiting_host_op + .as_ref() + .is_some_and(|waiting| { + matches!( + waiting.source, + crate::vm::host::WaitingHostOpSource::HostBridge + ) + }) + { + return match self.poll_waiting_host_op(context) { + Poll::Pending => DriveOutcome::Pending, + Poll::Ready(Ok(())) => DriveOutcome::Error(InvocationError::Cancelled(reason)), + Poll::Ready(Err(error)) => { + DriveOutcome::Error(self.map_invocation_error(error)) + } + }; + } + return DriveOutcome::Error(InvocationError::Cancelled(reason)); + } + match self.run() { + Ok(VmStatus::Halted) => { + let result = match self.take_invocation_result() { + Some(result) => result, + None => { + return DriveOutcome::Error(InvocationError::Vm( + VmError::InvalidFrameState( + "invocation halted without a callable result", + ), + )); + } + }; + self.instance + .invocation + .as_mut() + .expect("invocation state") + .phase = InvocationPhase::CompletePending(result); + DriveOutcome::Continue + } + Ok(VmStatus::Yielded) => match self.last_yield_reason() { + Some(VmYieldReason::Fuel) => DriveOutcome::Error(InvocationError::OutOfFuel { + needed: u64::from(self.run_ctx.fuel_check_interval), + remaining: self.run_ctx.fuel_remaining, + }), + Some(VmYieldReason::Epoch) => { + DriveOutcome::Error(InvocationError::DeadlineReached { + current: self.run_ctx.epoch_handle.current(), + deadline: self.run_ctx.epoch_deadline, + }) + } + _ => { + // A `stream::emit` yield leaves one pending event; any other + // host-driven yield is paused for the embedding. + let event_pending = matches!( + self.instance.invocation.as_ref().map(|state| &state.phase), + Some(InvocationPhase::EventPending(_)) + ); + if event_pending { + DriveOutcome::Continue + } else { + DriveOutcome::Pending + } + } + }, + Ok(VmStatus::Waiting(_)) => { + // Forward the caller's context so the embedding-owned driver + // can wake the task when the operation progresses. + match self.poll_waiting_host_op(context) { + Poll::Ready(Ok(())) => DriveOutcome::Continue, + Poll::Ready(Err(error)) => { + DriveOutcome::Error(self.map_invocation_error(error)) + } + Poll::Pending => DriveOutcome::Pending, + } + } + Err(error) => DriveOutcome::Error(self.map_invocation_error(error)), + } + } + + /// Maps a low-level VM failure to the typed invocation error, preserving + /// structured runtime errors from `stream::emit` validation. + fn map_invocation_error(&mut self, error: VmError) -> InvocationError { + if let Some(state) = self.instance.invocation.as_mut() + && let Some(runtime_error) = state.pending_error.take() + { + return InvocationError::Capability(runtime_error); + } + match error { + VmError::OutOfFuel { needed, remaining } => { + InvocationError::OutOfFuel { needed, remaining } + } + VmError::EpochDeadlineReached { current, deadline } => { + InvocationError::DeadlineReached { current, deadline } + } + VmError::ExecutionScope(scope_error) => InvocationError::Capability(RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "execution_scope", + scope_error.to_string(), + )), + VmError::HostError(message) => InvocationError::Host { message }, + other => InvocationError::Vm(other), + } + } + + /// Replaces the active invocation phase, returning the previous one so the + /// caller can consume it or drop it (the pending-event drop contract stays + /// with the caller). + fn replace_invocation_phase(&mut self, phase: InvocationPhase) -> InvocationPhase { + std::mem::replace( + &mut self + .instance + .invocation + .as_mut() + .expect("invocation state") + .phase, + phase, + ) + } + + /// Releases the active invocation: cancels outstanding waiting host + /// operations, drops interpreter frames and stack entries introduced by + /// the invocation, and fuses the stream. The per-invocation cancellation + /// reason is cleared by the drop, so it cannot leak into a later + /// invocation started on the same VM. + fn release_invocation(&mut self) { + let (stack_base, frame_count, cancel_reason) = self + .instance + .invocation + .as_ref() + .map(|state| (state.stack_base, state.frame_count, state.cancel_reason)) + .unwrap_or((0, 0, None)); + let _ = self.cancel_waiting_host_op_with_reason( + cancel_reason.unwrap_or(OperationCancelReason::Requested), + ); + self.abort_host_invocation(stack_base, frame_count); + self.instance.drop_invocation_state(); + } + + /// Implements the script-visible `stream::emit(value)` builtin: validates + /// the per-item bound, places one pending event, and yields control to the + /// invocation poller. `stream::emit` still evaluates to `()` inside RSS. + /// + /// When the poller has delivered the event and the VM resumes, the call + /// site re-executes; the second entry consumes the `emit_yield_pending` + /// marker and returns normally instead of emitting a second event. + pub(crate) fn emit_stream_item(&mut self, value: Value) -> VmResult { + let state = self.instance.invocation.as_mut().ok_or_else(|| { + VmError::HostError("stream::emit requires an active invocation".to_string()) + })?; + if !matches!(state.phase, InvocationPhase::Running) { + return Err(VmError::HostError( + "stream::emit is only valid while the invocation is running".to_string(), + )); + } + if state.emit_yield_pending { + state.emit_yield_pending = false; + return Ok(CallOutcome::Return(CallReturn::none())); + } + let limits = self.run_ctx.runtime_context.event_limits(); + match EventPayload::try_new(value, limits) { + Ok(payload) => { + state.phase = InvocationPhase::EventPending(payload.into_value()); + state.emit_yield_pending = true; + Ok(CallOutcome::Yield) + } + Err(runtime_error) => { + let message = runtime_error.to_string(); + state.pending_error = Some(runtime_error); + Err(VmError::HostError(message)) + } + } + } +} + +/// Outcome of one low-level drive step. +enum DriveOutcome { + Continue, + Pending, + Error(InvocationError), +} diff --git a/src/vm/jit/inline.rs b/src/vm/jit/inline.rs index 0a0cc1e0..c1c0fc22 100644 --- a/src/vm/jit/inline.rs +++ b/src/vm/jit/inline.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::builtins::BuiltinFunction; +use crate::BuiltinFunction; use crate::vm::native::ROOT_FRAME_KEY; use crate::{CallableKind, CallableTarget, OpCode, Program}; diff --git a/src/vm/jit/recorder.rs b/src/vm/jit/recorder.rs index 769d14da..b163024f 100644 --- a/src/vm/jit/recorder.rs +++ b/src/vm/jit/recorder.rs @@ -1,6 +1,6 @@ use std::fmt; -use crate::builtins::BuiltinFunction; +use crate::BuiltinFunction; use crate::compiler::TypeSchema; use crate::vm::{OpCode, Program, Value, ValueType, checked_int_div}; diff --git a/src/vm/jit/runtime.rs b/src/vm/jit/runtime.rs index e4d946a0..65b4d8dc 100644 --- a/src/vm/jit/runtime.rs +++ b/src/vm/jit/runtime.rs @@ -1241,6 +1241,7 @@ impl Vm { let op_id = self .instance .waiting_host_op + .as_ref() .map(|op| op.op_id) .ok_or_else(|| { VmError::JitNative( diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 986dbb64..57ab14f9 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -2,43 +2,67 @@ use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; pub(crate) mod aot; +pub mod async_host; +mod capability; pub mod diagnostics; mod engine; mod epoch; pub mod execution_scope; mod fuel; mod host; +pub mod host_context; +pub mod host_extension; mod host_runtime; pub(crate) mod host_state; mod instance; +pub mod invocation; pub(crate) mod jit; mod map_iter; pub(crate) mod native; pub mod operation; pub mod program; +pub(crate) mod regex_cache; pub mod resource; mod run_context; +pub mod runtime; +pub mod standard_composition; +pub(crate) mod standard_ops; mod store; mod superinstructions; #[cfg(test)] mod tests; pub use self::aot::AotArtifactError; +pub use self::async_host::{CaptureAsyncHostContext, HostFuture, HostFutureOutput}; +pub use self::capability::{CapabilityProfile, CapabilityProfileBuilder}; use self::engine::Engine; pub use self::epoch::{EpochCheckpoint, EpochHandle}; use self::execution_scope::ExecutionScopeError; pub use self::fuel::FuelCheckpoint; pub use self::host::{ - CallOutcome, CallReturn, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, - HostFunctionRegistry, HostOpId, HostStackFunction, StaticHostArgsFunction, StaticHostFunction, - StaticHostStackFunction, + CallOutcome, CallReturn, HostArgsFunction, HostAsyncBridge, HostAsyncOpTerminal, + HostBindingPlan, HostFunction, HostFunctionRegistry, HostOpId, HostStackFunction, + RegistrySchemaError, StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, }; use self::host::{HostCallExecOutcome, VmHostFunction}; +pub use self::host_context::{ + HostContext, HostContextError, HostContextErrorKind, HostContextResult, HostModule, + HostModule as HostModuleState, +}; +pub use self::host_extension::{ + CatalogRegistrationError, CatalogSchemaSelection, HostExtension, HostImportParam, + HostImportSchema, catalog_import_schemas, register_catalog_function, + register_catalog_static_function, validate_catalog_import_schemas, + validate_catalog_import_schemas_with_fingerprints, +}; use self::host_runtime::HostRuntime; use self::instance::{ExecutionFrame, FrameContinuation, Instance, QueuedCallable}; +pub use self::invocation::{Invocation, InvocationError, InvocationItem, InvocationPoll}; pub use self::resource::ResourceCloseReason; use self::run_context::{InterruptMode, RunContext}; +pub use self::standard_composition::StandardSurfaceComposition; pub use crate::bytecode::{ CallableTarget, CallableValue, HostImport, OpCode, Program, Value, ValueType, }; @@ -362,6 +386,11 @@ fn compute_program_cache_key(program: &Program) -> u64 { hash_value(constant, &mut hasher); } program.imports.hash(&mut hasher); + let has_host_import_schemas = program.host_import_schemas.iter().any(Option::is_some); + has_host_import_schemas.hash(&mut hasher); + if has_host_import_schemas { + program.host_import_schemas.hash(&mut hasher); + } program.script_functions.hash(&mut hasher); program.function_regions.hash(&mut hasher); program.root_callable_bindings.hash(&mut hasher); @@ -552,7 +581,7 @@ impl Vm { engine, instance, run_ctx: RunContext::default(), - host: HostRuntime::default(), + host: HostRuntime::with_standard_composition(crate::standard_composition()), } } @@ -692,14 +721,81 @@ impl Vm { /// /// Locals are reset to `Null`, stack is cleared, and instruction pointer is /// rewound to the program entry. In-flight IO work and live IO handles are - /// retired through the generic execution-scope lifecycle (the old scope is - /// dropped and replaced with a fresh one). - pub fn reset_for_reuse(&mut self) { - self.cancel_waiting_host_op(); - self.host.reset_execution_scope(); + /// retired through the generic execution-scope lifecycle. If generic close + /// is still pending, the old scope remains retained and VM execution is + /// blocked until `poll_reset_for_reuse` reaches quiescence. + pub fn reset_for_reuse(&mut self) -> VmResult<()> { + self.cancel_waiting_host_op_with_reason( + crate::vm::operation::OperationCancelReason::VmReset, + )?; + if let Err(error) = self.host.reset_execution_scope() { + self.instance.invalidate_callback_registries(); + return Err(error); + } self.run_ctx.reset_for_reuse(); self.instance.reset(&self.program); self.engine.reset_runtime_state(&self.program); + Ok(()) + } + + /// Whether a reset is still waiting for the generic execution scope to + /// reach quiescence. A pending reset blocks VM execution and pool reuse. + pub fn scope_reset_pending(&self) -> bool { + self.host.scope_reset_pending + } + + /// Whether a reset has reached a terminal error. A terminal reset error + /// prevents execution and callback publication until the VM is replaced. + pub(crate) fn scope_reset_error(&self) -> Option<&ExecutionScopeError> { + self.host.scope_reset_error() + } + + /// Whether the VM is safe to return to a reuse pool. The execution scope + /// must still be Active and no reset may be waiting on the old scope. + pub fn is_reusable(&self) -> bool { + self.host.is_reusable() + && self.run_ctx.is_reusable() + && self.instance.is_reusable(&self.program) + } + + /// Polls a reset's generic operation/resource close boundary. + /// + /// A replacement scope is created only after this returns `Ready(Ok(()))`. + /// 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> { + match self.host.poll_reset_execution_scope(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(())) => Poll::Ready(Ok(())), + Poll::Ready(Err(error)) => Poll::Ready(Err(error)), + } + } + + fn ensure_scope_ready(&mut self) -> VmResult<()> { + if self.instance.shutdown { + return Err(VmError::InvalidFrameState("vm is shut down")); + } + if let Some(error) = self.host.scope_reset_error().cloned() { + return Err(VmError::ExecutionScope(error)); + } + if !self.host.scope_reset_pending { + if self.host.has_pending_bridge_cancellations() { + return Err(VmError::HostError( + "bridge host operation is not quiescent".to_string(), + )); + } + if self.host.execution_scope.is_active() { + return Ok(()); + } + return Err(VmError::ExecutionScope(ExecutionScopeError::ScopeClosing)); + } + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + match self.poll_reset_for_reuse(&mut cx) { + Poll::Ready(Ok(())) => Ok(()), + Poll::Ready(Err(error)) => Err(error), + Poll::Pending => Err(VmError::ExecutionScope(ExecutionScopeError::ScopeClosing)), + } } fn validate_map_iterator_slot(&self, slot: usize) -> VmResult<()> { @@ -977,6 +1073,7 @@ impl Vm { } pub fn run(&mut self) -> VmResult { + self.ensure_scope_ready()?; self.run_internal(None, true) } @@ -984,13 +1081,18 @@ impl Vm { &mut self, debugger: &mut crate::debugger::Debugger, ) -> VmResult { + self.ensure_scope_ready()?; self.run_internal(Some(debugger), false) } } impl Drop for Vm { fn drop(&mut self) { - self.cancel_waiting_host_op(); + let _ = self.cancel_waiting_host_op_with_reason( + crate::vm::operation::OperationCancelReason::VmDrop, + ); + self.host + .cancel_submitted_host_ops(crate::vm::operation::OperationCancelReason::VmDrop); self.instance.drop_cleanup(); // Live IO handles and in-flight IO operations are retired by the // `ExecutionScope`'s own `Drop`, which runs as part of `HostRuntime`. @@ -2078,7 +2180,9 @@ impl Vm { debugger: Option<&mut crate::debugger::Debugger>, allow_jit: bool, ) -> VmResult { + self.instance.run_depth = self.instance.run_depth.saturating_add(1); let result = self.run_internal_impl(debugger, allow_jit); + self.instance.run_depth = self.instance.run_depth.saturating_sub(1); if result.is_err() { self.close_all_map_iterators(); } @@ -2125,7 +2229,7 @@ impl Vm { ) -> VmResult { self.ensure_call_bindings()?; self.sync_jit_non_yielding_host_imports(); - if let Some(waiting) = self.instance.waiting_host_op { + if let Some(waiting) = self.instance.waiting_host_op.as_ref() { self.instance.last_yield_reason = None; let status = VmStatus::Waiting(waiting.op_id); self.notify_debugger_status(&mut debugger, status); @@ -2628,6 +2732,7 @@ impl Vm { } pub fn resume(&mut self) -> VmResult { + self.ensure_scope_ready()?; let allow_jit = !matches!( self.instance .execution_frames @@ -2671,6 +2776,30 @@ impl Vm { &mut self.host.execution_scope } + /// Returns the generic host boundary for this VM. + /// + /// [`HostContext`](crate::vm::host_context::HostContext) exposes typed + /// per-VM module state and the generic host-agnostic execution-scope SDK + /// to external host extensions without leaking the underlying host runtime + /// or naming a builtin domain module. + pub fn host_context(&mut self) -> crate::vm::host_context::HostContext<'_> { + crate::vm::host_context::HostContext::new(self) + } + + /// Installs a [`HostExtension`](crate::vm::host_extension::HostExtension) + /// onto this VM. + /// + /// Registration (into the VM's bound host-function registry) is + /// transactional and runs before the install phase, so a fallible + /// registration/registry-binding failure surfaces before any per-VM + /// module state is installed. + pub fn install_extension( + &mut self, + extension: &dyn crate::vm::host_extension::HostExtension, + ) -> VmResult<()> { + extension.install_into(self) + } + pub fn has_bound_function(&self, name: &str) -> bool { self.host.host_function_symbols.contains_key(name) } @@ -2811,7 +2940,12 @@ impl Vm { pub fn shutdown(&mut self) { self.invalidate_callback_registries(); - self.cancel_waiting_host_op(); + let _ = self.cancel_waiting_host_op_with_reason( + crate::vm::operation::OperationCancelReason::VmDrop, + ); + self.host + .cancel_submitted_host_ops(crate::vm::operation::OperationCancelReason::VmDrop); + self.host.scoped_operation_completions.clear(); // Begin execution-scope shutdown (first-reason-wins; sealing the // operation registry) before tearing down interpreter state. let _ = self @@ -2832,6 +2966,7 @@ impl Vm { self.instance.call_depth = 0; self.instance.host_return = None; self.instance.waiting_host_op = None; + self.instance.drop_invocation_state(); self.instance.shutdown = true; } @@ -2844,6 +2979,7 @@ impl Vm { } pub fn start_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { + self.ensure_scope_ready()?; if self.instance.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } @@ -2967,13 +3103,20 @@ impl Vm { } self.instance.call_depth = self.script_frame_depth(); self.instance.host_return = None; - self.cancel_waiting_host_op(); + let _ = self.cancel_waiting_host_op(); self.instance.last_yield_reason = None; self.instance .map_iterators .truncate(self.instance.call_depth.saturating_add(1)); } + pub(crate) fn take_invocation_result(&mut self) -> Option { + self.instance.host_return.take() + } + + /// 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. pub fn take_callable_result(&mut self) -> Option { self.instance .completed_callable_results diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index 357f6b21..4f1e3e2d 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -1,6 +1,7 @@ #![allow(dead_code)] -use crate::builtins::BuiltinFunction; +use crate::BuiltinFunction; use crate::bytecode::{CallableKind, CallableTarget, Value, ValueType, VmMap}; +use crate::host_api::HostImportSchema; use crate::vm::{ CallOutcome, CallReturn, ExecOutcome, ExecutionFrame, FrameContinuation, HostCallExecOutcome, NumericValue, Vm, VmError, VmHostFunction, VmResult, logical_shr_i64, @@ -505,12 +506,10 @@ pub(crate) extern "C" fn pd_vm_native_string_contains( ) -> i32 { let text = unsafe { std::mem::ManuallyDrop::new(arc_from_repr_ptr::(text_ptr)) }; let needle = unsafe { std::mem::ManuallyDrop::new(arc_from_repr_ptr::(needle_ptr)) }; - i32::from( - crate::builtins::runtime::core::builtin_string_contains_impl( - text.as_str(), - needle.as_str(), - ), - ) + i32::from(crate::vm::standard_ops::string_contains( + text.as_str(), + needle.as_str(), + )) } pub(crate) extern "C" fn pd_vm_native_regex_match( @@ -526,8 +525,7 @@ pub(crate) extern "C" fn pd_vm_native_regex_match( }; let pattern = unsafe { std::mem::ManuallyDrop::new(arc_from_repr_ptr::(pattern_ptr)) }; let text = unsafe { std::mem::ManuallyDrop::new(arc_from_repr_ptr::(text_ptr)) }; - match crate::builtins::runtime::regex::native_re_match(vm_ref, pattern.as_str(), text.as_str()) - { + match vm_ref.standard_regex_match(pattern.as_str(), text.as_str()) { Ok(matched) => i32::from(matched), Err(err) => { store_bridge_error(err); @@ -552,12 +550,7 @@ pub(crate) extern "C" fn pd_vm_native_regex_replace( let text = unsafe { std::mem::ManuallyDrop::new(arc_from_repr_ptr::(text_ptr)) }; let replacement = unsafe { std::mem::ManuallyDrop::new(arc_from_repr_ptr::(replacement_ptr)) }; - match crate::builtins::runtime::regex::native_re_replace( - vm_ref, - pattern.as_str(), - text.as_str(), - replacement.as_str(), - ) { + match vm_ref.standard_regex_replace(pattern.as_str(), text.as_str(), replacement.as_str()) { Ok(replaced) => arc_into_repr_ptr(Arc::new(replaced)), Err(err) => { store_bridge_error(err); @@ -578,20 +571,18 @@ pub(crate) extern "C" fn pd_vm_native_string_replace_literal( if !needle.is_empty() && !text.contains(needle.as_str()) { return arc_into_repr_ptr(Arc::clone(&*text)); } - arc_into_repr_ptr(Arc::new( - crate::builtins::runtime::core::builtin_string_replace_literal_impl( - text.as_str(), - needle.as_str(), - replacement.as_str(), - ), - )) + arc_into_repr_ptr(Arc::new(crate::vm::standard_ops::string_replace_literal( + text.as_str(), + needle.as_str(), + replacement.as_str(), + ))) } pub(crate) extern "C" fn pd_vm_native_string_lower_ascii(text_ptr: *mut u8) -> *mut u8 { let text = unsafe { std::mem::ManuallyDrop::new(arc_from_repr_ptr::(text_ptr)) }; - arc_into_repr_ptr(Arc::new( - crate::builtins::runtime::core::builtin_string_lower_ascii_impl(text.as_str()), - )) + arc_into_repr_ptr(Arc::new(crate::vm::standard_ops::string_lower_ascii( + text.as_str(), + ))) } pub(crate) extern "C" fn pd_vm_native_type_of(value_ptr: *const Value) -> *mut u8 { @@ -611,9 +602,7 @@ pub(crate) extern "C" fn pd_vm_native_type_of(value_ptr: *const Value) -> *mut u pub(crate) extern "C" fn pd_vm_native_to_string(value_ptr: *const Value) -> *mut u8 { let value = unsafe { &*value_ptr }; - arc_into_repr_ptr(Arc::new( - crate::builtins::runtime::core::builtin_to_string_impl(value), - )) + arc_into_repr_ptr(Arc::new(crate::vm::standard_ops::value_to_string(value))) } pub(crate) extern "C" fn pd_vm_native_string_split_literal( @@ -623,12 +612,10 @@ pub(crate) extern "C" fn pd_vm_native_string_split_literal( let text = unsafe { std::mem::ManuallyDrop::new(arc_from_repr_ptr::(text_ptr)) }; let delimiter = unsafe { std::mem::ManuallyDrop::new(arc_from_repr_ptr::(delimiter_ptr)) }; - arc_into_repr_ptr(Arc::new( - crate::builtins::runtime::core::builtin_string_split_literal_impl( - text.as_str(), - delimiter.as_str(), - ), - )) + arc_into_repr_ptr(Arc::new(crate::vm::standard_ops::string_split_literal( + text.as_str(), + delimiter.as_str(), + ))) } pub(crate) extern "C" fn pd_vm_native_clone_value_to_slot( @@ -1365,7 +1352,7 @@ pub(crate) extern "C" fn pd_vm_native_map_has(repr_ptr: *mut u8, key: *const Val } let key = unsafe { &*key }; - if let Err(err) = crate::builtins::runtime::core::ensure_supported_map_key(key) { + if let Err(err) = crate::vm::standard_ops::ensure_supported_map_key(key) { store_bridge_error(err); return STATUS_ERROR; } @@ -1388,7 +1375,7 @@ pub(crate) extern "C" fn pd_vm_native_map_get( } let key = unsafe { &*key }; - if let Err(err) = crate::builtins::runtime::core::ensure_supported_map_key(key) { + if let Err(err) = crate::vm::standard_ops::ensure_supported_map_key(key) { store_bridge_error(err); return STATUS_ERROR; } @@ -1477,7 +1464,7 @@ pub(crate) extern "C" fn pd_vm_native_collection_set( let container = unsafe { std::ptr::replace(container, Value::Null) }; let key = unsafe { (&*key).clone() }; let value = unsafe { (&*value).clone() }; - match crate::builtins::runtime::core::builtin_set_owned(container, key, value) { + match crate::vm::standard_ops::set_owned(container, key, value) { Ok(result) => { let previous = unsafe { std::ptr::replace(dst, result) }; drop(previous); @@ -1568,7 +1555,7 @@ pub(crate) extern "C" fn pd_vm_native_map_set( } let key = unsafe { (&*key).clone() }; let value = unsafe { (&*value).clone() }; - if let Err(err) = crate::builtins::runtime::core::ensure_supported_map_key(&key) { + if let Err(err) = crate::vm::standard_ops::ensure_supported_map_key(&key) { store_bridge_error(err); return STATUS_ERROR; } @@ -1580,9 +1567,7 @@ pub(crate) extern "C" fn pd_vm_native_map_set( return STATUS_ERROR; } }; - let result = Value::Map(crate::builtins::runtime::core::builtin_set_map_shared_impl( - entries, key, value, - )); + let result = Value::Map(crate::vm::standard_ops::set_map_shared(entries, key, value)); let previous = unsafe { std::ptr::replace(dst, result) }; drop(previous); STATUS_CONTINUE @@ -1613,8 +1598,7 @@ pub(crate) extern "C" fn pd_vm_native_array_push( return STATUS_ERROR; } }; - let result = - Value::Array(crate::builtins::runtime::core::builtin_array_push_shared_impl(values, value)); + let result = Value::Array(crate::vm::standard_ops::array_push_shared(values, value)); let previous = unsafe { std::ptr::replace(dst, result) }; drop(previous); STATUS_CONTINUE @@ -1646,7 +1630,18 @@ pub(crate) extern "C" fn pd_vm_native_non_yielding_host_call( .imports .get(import) .map(|host_import| host_import.return_type); - match call_non_yielding_host_value(vm, import, args, expected_return_type) { + let expected_return_schema = vm + .program + .host_import_schemas + .get(import) + .and_then(Clone::clone); + match call_non_yielding_host_value( + vm, + import, + args, + expected_return_type, + expected_return_schema.as_ref(), + ) { Ok(value) => { unsafe { std::ptr::write(out, value) }; STATUS_CONTINUE @@ -1663,6 +1658,7 @@ fn call_non_yielding_host_value( import: usize, args: &[Value], expected_return_type: Option, + expected_return_schema: Option<&HostImportSchema>, ) -> VmResult { let resolved = *vm .host @@ -1680,11 +1676,16 @@ fn call_non_yielding_host_value( vm.instance.call_depth = vm.instance.call_depth.saturating_add(1); let outcome = function(args); vm.instance.call_depth = vm.instance.call_depth.saturating_sub(1); - outcome - .and_then(crate::vm::host::require_non_yielding_host_value) - .and_then(|value| { - crate::vm::host::validate_non_yielding_host_value(value, expected_return_type) - }) + let value = outcome.and_then(crate::vm::host::require_non_yielding_host_value)?; + let returned = CallReturn::one(value.clone()); + crate::vm::host::validate_host_call_return( + &returned, + expected_return_type, + expected_return_schema, + &vm.program, + vm.host.execution_scope.resources(), + )?; + Ok(value) } fn scalar_host_return_type(return_type: i64) -> VmResult { @@ -1742,8 +1743,21 @@ pub(crate) extern "C" fn pd_vm_native_non_yielding_scalar_host_call( return STATUS_ERROR; } let args = unsafe { std::slice::from_raw_parts(args, argc) }; + let expected_return_schema = vm + .program + .host_import_schemas + .get(import) + .and_then(Clone::clone); match scalar_host_return_type(return_type) - .and_then(|expected| call_non_yielding_host_value(vm, import, args, Some(expected))) + .and_then(|expected| { + call_non_yielding_host_value( + vm, + import, + args, + Some(expected), + expected_return_schema.as_ref(), + ) + }) .and_then(|value| store_scalar_host_result(value, return_type, out)) { Ok(()) => STATUS_CONTINUE, @@ -1776,9 +1790,20 @@ pub(crate) extern "C" fn pd_vm_native_non_yielding_i64_host_call( return STATUS_ERROR; } let storage = [Value::Int(arg0), Value::Int(arg1)]; + let expected_return_schema = vm + .program + .host_import_schemas + .get(import) + .and_then(Clone::clone); match scalar_host_return_type(return_type) .and_then(|expected| { - call_non_yielding_host_value(vm, import, &storage[..argc], Some(expected)) + call_non_yielding_host_value( + vm, + import, + &storage[..argc], + Some(expected), + expected_return_schema.as_ref(), + ) }) .and_then(|value| store_scalar_host_result(value, return_type, out)) { diff --git a/src/vm/operation/driver.rs b/src/vm/operation/driver.rs index 1f027b8a..d91ec8bc 100644 --- a/src/vm/operation/driver.rs +++ b/src/vm/operation/driver.rs @@ -73,9 +73,15 @@ pub trait HostOperation: Any + Send + 'static { /// Registers a waker for the transition to quiescent after cancellation. fn register_quiescence_waker(&mut self, _cx: &Context<'_>) {} - /// Cancels and, when a resource is already in its close phase, waits for - /// the driver's worker to terminate. The default is appropriate for - /// drivers without separate background work. + /// Cancels and waits for the driver's worker to terminate. + /// + /// This method is the cancellation/quiescence boundary. Implementations + /// must not return until the underlying work no longer needs the operation + /// slot, including when returning an error. An error reports cancellation + /// failure to the caller; it does not permit the operation registry to keep + /// an occupied failed slot. + /// + /// The default is appropriate for drivers without separate background work. fn cancel_and_wait(&mut self, reason: OperationCancelReason) -> OperationResult<()> { self.cancel(reason) } diff --git a/src/vm/operation/registry.rs b/src/vm/operation/registry.rs index 40dc87ae..b3ef9859 100644 --- a/src/vm/operation/registry.rs +++ b/src/vm/operation/registry.rs @@ -222,6 +222,26 @@ impl OperationRegistry { Ok(outcome) } + /// Consumes a terminal outcome after `cancel_and_wait` has returned. + /// + /// `cancel_and_wait` is the driver's quiescence boundary: its contract + /// requires the underlying work to be stopped before it returns, including + /// when it returns an error. This path therefore releases the slot without + /// asking the driver for a second quiescence observation. + fn take_outcome_after_cancel_and_wait( + &mut self, + id: OperationId, + ) -> OperationResult { + let slot = self.location(id)?; + let outcome = self.slots[slot] + .operation + .as_ref() + .and_then(|operation| operation.status.terminal_outcome()) + .ok_or_else(|| pending_outcome(id))?; + self.release_slot(slot); + Ok(outcome) + } + /// Drives the operation one step. /// /// Polls the owning driver first; a `Ready` driver result wins even if a @@ -392,10 +412,10 @@ impl OperationRegistry { } /// Aborts a started operation that must never produce a guest-visible - /// result: cancels the driver exactly once if it is still pending, then - /// consumes/immediately releases the slot so the id becomes stale and - /// full registry capacity is restored (the same "cancel then consume" - /// sequence the batch drain helpers use). + /// result: cancels the driver exactly once if it is still pending, waits + /// through the driver's `cancel_and_wait` boundary, then consumes/releases + /// the slot so the id becomes stale and full registry capacity is restored + /// (the same "cancel then consume" sequence the batch drain helpers use). /// /// This is the rollback counterpart to [`start`](Self::start), for call /// sites that register an operation and then hit a fallible handoff @@ -404,18 +424,19 @@ impl OperationRegistry { /// - **Pending** — the driver is cancelled exactly once with `reason` /// (first-reason-wins), the resulting terminal outcome is consumed and /// the slot released, and `Ok(true)` is returned. If the driver's - /// `cancel` itself fails, that failure is recorded as the first - /// `Failed` status, the cleanup runs once, the slot is still released, - /// and the driver error is returned — the slot is never left occupied - /// regardless of the cancel outcome. + /// `cancel_and_wait` boundary returns an error, that failure is recorded + /// as the first `Failed` status, the cleanup runs once, the slot is still + /// released, and the driver error is returned — the slot is never left + /// occupied regardless of the cancellation outcome. /// - **Already terminal** — the terminal outcome is consumed, the slot /// released, and `Ok(false)` returned (the driver is not invoked again). /// - **Stale / foreign / out-of-range** — rejected with the usual typed /// error and **no** registry mutation. /// - /// After a successful abort the id is stale under an incremented slot - /// generation, so a later `poll`, `status`, `take_outcome`, `remove` or - /// second `abort` on it all report `OperationStale`. + /// After aborting a valid pending operation, whether cancellation returns + /// `Ok` or an error, the id is stale under an incremented slot generation, + /// so a later `poll`, `status`, `take_outcome`, `remove` or second `abort` + /// on it all report `OperationStale`. pub fn abort( &mut self, id: OperationId, @@ -423,21 +444,48 @@ impl OperationRegistry { ) -> OperationResult { // Validate fully before any mutation; an unresolvable id is rejected // without touching cancel/consume state. - let _slot = self.location(id)?; + let slot = self.location(id)?; + let pending = self.slots[slot] + .operation + .as_ref() + .is_some_and(|operation| matches!(operation.status, OperationStatus::Pending)); let cancel_result = self.cancel_with_wait(id, reason, true); - // Whether the driver cancelled cleanly, the driver's cancel failed - // (the entry is now terminal `Failed`), or the entry was already - // terminal before this call, consuming the outcome releases the slot - // and makes the id stale exactly once. Preserve the first transition - // error, while still surfacing an outcome-consumption error when the - // cancellation itself succeeded. - let take_result = self.take_outcome(id); + // A pending operation has crossed the driver's cancel-and-wait + // boundary, so release its terminal slot even when that boundary + // returned an error. Already-terminal operations still use the normal + // quiescence-checked take path. + let take_result = if pending { + self.take_outcome_after_cancel_and_wait(id) + } else { + self.take_outcome(id) + }; match (cancel_result, take_result) { (Err(error), _) | (Ok(_), Err(error)) => Err(error), (Ok(cancelled), Ok(_)) => Ok(cancelled), } } + /// Cancels every pending operation through the driver's synchronous + /// `cancel_and_wait` boundary and consumes each terminal slot. This is used + /// by VM reset, where the pool cannot retain a pending operation across the + /// reset boundary. Drivers with background work must implement + /// `cancel_and_wait` so it returns only after that work is quiescent. + pub fn cancel_all_and_wait(&mut self, reason: OperationCancelReason) -> OperationCancelSummary { + let mut summary = OperationCancelSummary::default(); + for id in self.occupied_ids() { + let pending = self + .location(id) + .ok() + .and_then(|slot| self.slots[slot].operation.as_ref()) + .is_some_and(|operation| matches!(operation.status, OperationStatus::Pending)); + if !pending { + continue; + } + summary.record(self.abort(id, reason)); + } + summary + } + /// Cancels every pending operation and records the outcome in a /// [`OperationCancelSummary`]. This is intentionally *cancel-only*: it /// records the first cancellation reason on each still-pending driver @@ -984,6 +1032,33 @@ mod tests { } } + /// Driver that reports a cancellation error from the blocking cancellation + /// boundary while deliberately reporting a non-quiescent worker. `abort` + /// must still retire the slot after `cancel_and_wait` returns. + struct CancelAndWaitFailDriver; + + impl HostOperation for CancelAndWaitFailDriver { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + + fn cancel(&mut self, _reason: OperationCancelReason) -> OperationResult<()> { + Ok(()) + } + + fn cancel_and_wait(&mut self, _reason: OperationCancelReason) -> OperationResult<()> { + Err(OperationError::new( + OperationErrorCode::OperationDriverFailed, + "test", + "cancel-and-wait failed after reaching its boundary", + )) + } + + fn is_quiescent(&self) -> bool { + false + } + } + #[test] fn start_assigns_distinct_ids_and_capacity_is_bounded() { let mut registry = OperationRegistry::with_limit(2).expect("registry"); @@ -1206,6 +1281,30 @@ mod tests { .expect("capacity restored"); } + #[test] + fn abort_releases_slot_even_when_cancel_and_wait_fails() { + let mut registry = OperationRegistry::with_limit(1).expect("registry"); + let id = registry + .start(OperationSpec::new(CancelAndWaitFailDriver)) + .expect("start"); + + let error = registry + .abort(id, OperationCancelReason::VmReset) + .expect_err("cancel-and-wait failure surfaces"); + assert_eq!(error.code(), OperationErrorCode::OperationDriverFailed); + assert_eq!(registry.len(), 0, "failed abort must retire the slot"); + assert_eq!( + registry + .status(id) + .expect_err("retired operation id is stale") + .code(), + OperationErrorCode::OperationStale + ); + registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("capacity is restored after failed abort"); + } + #[test] fn abort_on_already_terminal_removes_without_cancelling_again() { let mut registry = OperationRegistry::with_limit(2).expect("registry"); diff --git a/src/vm/regex_cache.rs b/src/vm/regex_cache.rs new file mode 100644 index 00000000..c9a0fd87 --- /dev/null +++ b/src/vm/regex_cache.rs @@ -0,0 +1,93 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use regex::Regex; + +pub(crate) const DEFAULT_REGEX_CACHE_CAPACITY: usize = 512; + +/// Per-VM bounded regex cache shared by the generic execution engines. +pub(crate) struct RegexCache { + capacity: usize, + entries: HashMap>, + recency: VecDeque, + compile_count: u64, + hit_count: u64, +} + +impl Default for RegexCache { + fn default() -> Self { + Self::with_capacity(DEFAULT_REGEX_CACHE_CAPACITY) + } +} + +impl RegexCache { + pub(crate) fn with_capacity(capacity: usize) -> Self { + Self { + capacity, + entries: HashMap::new(), + recency: VecDeque::new(), + compile_count: 0, + hit_count: 0, + } + } + + pub(crate) fn get_or_compile(&mut self, pattern: &str) -> Result, regex::Error> { + if let Some(regex) = self.entries.get(pattern).cloned() { + self.hit_count = self.hit_count.saturating_add(1); + self.touch(pattern); + return Ok(regex); + } + + let regex = Arc::new(Regex::new(pattern)?); + self.compile_count = self.compile_count.saturating_add(1); + if self.capacity == 0 { + return Ok(regex); + } + while self.entries.len() >= self.capacity { + let Some(oldest) = self.recency.pop_front() else { + break; + }; + self.entries.remove(&oldest); + } + self.entries.insert(pattern.to_string(), regex.clone()); + self.recency.push_back(pattern.to_string()); + Ok(regex) + } + + fn touch(&mut self, pattern: &str) { + if let Some(index) = self.recency.iter().position(|entry| entry == pattern) { + self.recency.remove(index); + } + self.recency.push_back(pattern.to_string()); + } + + pub(crate) fn capacity(&self) -> usize { + self.capacity + } + + pub(crate) fn set_capacity(&mut self, capacity: usize) { + self.capacity = capacity; + while self.entries.len() > capacity { + let Some(oldest) = self.recency.pop_front() else { + self.entries.clear(); + break; + }; + self.entries.remove(&oldest); + } + if capacity == 0 { + self.recency.clear(); + } + } + + pub(crate) fn len(&self) -> usize { + self.entries.len() + } + + pub(crate) fn compile_count(&self) -> u64 { + self.compile_count + } + + pub(crate) fn hit_count(&self) -> u64 { + self.hit_count + } +} diff --git a/src/vm/resource/close.rs b/src/vm/resource/close.rs index 98ff87d7..ad6f8cdd 100644 --- a/src/vm/resource/close.rs +++ b/src/vm/resource/close.rs @@ -7,6 +7,8 @@ use std::any::Any; use std::task::{Context, Poll}; +use crate::host_api::ResourceTypeKey; + use super::error::ResourceResult; use super::reason::ResourceCloseReason; @@ -35,6 +37,19 @@ pub enum CloseProgress { /// The `Any` supertrait lets the table reconnect each erased value to its /// concrete `TypeId` without ever naming a concrete class. pub trait HostResource: Any + Send + 'static { + /// Stable catalog identity for this concrete resource declaration. + /// + /// New resource declarations should override this method. The default + /// keeps pre-existing host resources source-compatible; such resources + /// participate in legacy typed APIs but cannot satisfy an exact request + /// carrying a non-empty [`ResourceTypeKey`]. + fn resource_type_key() -> Option + where + Self: Sized, + { + None + } + /// Begins closing the resource, emitting a synchronous cancel/close request. /// /// The default is a synchronous no-op close. diff --git a/src/vm/resource/error.rs b/src/vm/resource/error.rs index 2cc73191..ed8b0421 100644 --- a/src/vm/resource/error.rs +++ b/src/vm/resource/error.rs @@ -31,6 +31,8 @@ pub enum ResourceErrorCode { /// A resource token named a concrete type that did not match the live /// resource's actual type. ResourceTypeMismatch, + /// A declared catalog resource key did not match the live/concrete key. + ResourceTypeKeyMismatch, /// A handle referred to a slot generation that had moved on (stale). ResourceStale, /// The resource was already closed or is in the middle of closing. @@ -71,6 +73,7 @@ impl ResourceErrorCode { Self::InvalidResourceHandle => "invalid_resource_handle", Self::ResourceHandleWrongTable => "resource_handle_wrong_table", Self::ResourceTypeMismatch => "resource_type_mismatch", + Self::ResourceTypeKeyMismatch => "resource_type_key_mismatch", Self::ResourceStale => "resource_stale", Self::ResourceAlreadyClosed => "resource_already_closed", Self::ResourceIdExhausted => "resource_id_exhausted", diff --git a/src/vm/resource/handle.rs b/src/vm/resource/handle.rs index cfa88100..f72b4a03 100644 --- a/src/vm/resource/handle.rs +++ b/src/vm/resource/handle.rs @@ -119,6 +119,55 @@ impl ResourceHandle { } } +/// A concrete value transferred out of a resource table by a TakeOwned host +/// parameter. +/// +/// `Resource` is the copyable capability token used while a value remains +/// in a scope. `ResourceOwned` is deliberately a different, non-token type: +/// once constructed, it owns the concrete `T` and the corresponding table slot +/// is vacant. The distinction prevents a taken value from being mistaken for a +/// live handle and documents the type name used by the host-function schema. +#[derive(Debug, PartialEq, Eq)] +pub struct ResourceOwned(T); + +impl ResourceOwned { + /// Wraps a value whose resource-table ownership has been transferred. + pub fn new(value: T) -> Self { + Self(value) + } + + /// Returns the transferred value to its caller. + pub fn into_inner(self) -> T { + self.0 + } +} + +impl AsRef for ResourceOwned { + fn as_ref(&self) -> &T { + &self.0 + } +} + +impl AsMut for ResourceOwned { + fn as_mut(&mut self) -> &mut T { + &mut self.0 + } +} + +impl std::ops::Deref for ResourceOwned { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for ResourceOwned { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + /// A type-marked capability token over one resource. /// /// `Resource` is `Copy` and cheap; it is a key into a table, not an owner. diff --git a/src/vm/resource/mod.rs b/src/vm/resource/mod.rs index 17b1eacb..53a7afe6 100644 --- a/src/vm/resource/mod.rs +++ b/src/vm/resource/mod.rs @@ -30,6 +30,6 @@ pub mod table; pub use self::close::{CloseProgress, HostResource}; pub use self::error::{ResourceError, ResourceErrorCode, ResourceResult}; -pub use self::handle::{Resource, ResourceHandle, ResourceMut, ResourceRef}; +pub use self::handle::{Resource, ResourceHandle, ResourceMut, ResourceOwned, ResourceRef}; pub use self::reason::ResourceCloseReason; pub use table::{CloseAllReport, ResourceTable}; diff --git a/src/vm/resource/reason.rs b/src/vm/resource/reason.rs index fb06d3c9..d584a347 100644 --- a/src/vm/resource/reason.rs +++ b/src/vm/resource/reason.rs @@ -97,7 +97,7 @@ mod tests { } /// Architecture guard: the resource support modules must stay free of -/// `crate::builtins` (and comment-only noise) so they can be reused without +/// `builtin registration paths` (and comment-only noise) so they can be reused without /// pulling in the core crate's builtin registry. The scan is dynamic: every /// production `.rs` file directly under `src/vm/resource/` is enumerated at /// test time, so any future module is covered automatically without editing diff --git a/src/vm/resource/table.rs b/src/vm/resource/table.rs index ac3a93ca..26bc170a 100644 --- a/src/vm/resource/table.rs +++ b/src/vm/resource/table.rs @@ -17,6 +17,8 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::task::{Context, Poll}; +use crate::host_api::ResourceTypeKey; + use super::close::{CloseProgress, HostResource}; use super::error::{ResourceError, ResourceErrorCode, ResourceResult}; use super::handle::{ @@ -101,6 +103,9 @@ struct ResourceSlot { generation: Cell, /// Concrete type of the current occupant; borrow-time validation only. type_id: TypeId, + /// Stable host-facing identity of the current occupant, when declared by + /// the concrete resource type. + resource_type_key: Option, /// The resource state is independently guarded so distinct frame requests /// may hold disjoint borrows without an aliased `&mut ResourceTable`. state: RefCell, @@ -231,6 +236,10 @@ impl ResourceTable { self.active_entries.get() == 0 } + pub(crate) fn is_clean(&self) -> bool { + self.is_empty() && self.scope_states.is_empty() + } + /// Number of physical slot entries ever carved out of the arena. /// /// Test-only: proves that close/reuse cycles return slots to the vacant @@ -571,6 +580,42 @@ impl ResourceTable { self.arena_id } + /// Removes an open typed resource from the table and transfers its concrete + /// value to the caller. + /// + /// Validation happens before the slot is changed. A wrong type, stale + /// generation, foreign arena, or active borrow therefore leaves the table + /// untouched. A successful take vacates the slot without invoking + /// [`HostResource::begin_close`], so the same token is rejected on the next + /// call and a later replacement receives a new generation. + pub fn take(&mut self, handle: ResourceHandle) -> ResourceResult { + let slot_index = self.resolve_index(handle)?; + self.check_type::(slot_index, handle)?; + + let mut slot_state = self.slots[slot_index] + .state + .try_borrow_mut() + .map_err(|_| resource_borrow_conflict_error(handle))?; + let state = std::mem::replace(&mut *slot_state, SlotState::Vacant); + drop(slot_state); + + match state { + SlotState::Open(resource) => { + self.reclaim(slot_index); + let resource: Box = resource; + resource + .downcast::() + .map(|value| *value) + .map_err(|_| type_mismatch(handle, TypeId::of::())) + } + SlotState::Closing(resource) => { + self.put_slot_state(slot_index, SlotState::Closing(resource)); + Err(already_closed_error(handle)) + } + SlotState::Vacant => Err(already_closed_error(handle)), + } + } + // ---- typed scope-state arena ------------------------------------------------- /// Returns a mutable handle to the `T`-typed scope state, creating it with @@ -794,6 +839,7 @@ impl ResourceTable { } let type_id = TypeId::of::(); + let resource_type_key = T::resource_type_key(); let value: Box = Box::new(value); let (slot_index, generation) = if let Some(slot_index) = self.vacant_slots.get_mut().pop() { @@ -805,6 +851,7 @@ impl ResourceTable { .expect("only reusable generations enter the vacant list"); self.slots[slot_index].generation.set(generation); self.slots[slot_index].type_id = type_id; + self.slots[slot_index].resource_type_key = resource_type_key; *self.slots[slot_index].state.get_mut() = SlotState::Open(value); (slot_index, generation) } else { @@ -820,6 +867,7 @@ impl ResourceTable { self.slots.push(ResourceSlot { generation: Cell::new(generation), type_id, + resource_type_key, state: RefCell::new(SlotState::Open(value)), }); (slot_index, generation) @@ -878,6 +926,47 @@ impl ResourceTable { } Ok(slot_index) } + + /// Validates a live resource handle against a declared catalog key without + /// borrowing, taking, closing, or otherwise mutating the resource. + pub fn validate_resource_type_key( + &self, + handle: ResourceHandle, + expected: &ResourceTypeKey, + ) -> ResourceResult<()> { + let slot_index = self.resolve_index(handle)?; + let state = self.slots[slot_index] + .state + .try_borrow() + .map_err(|_| resource_borrow_conflict_error(handle))?; + if !matches!(&*state, SlotState::Open(_)) { + return Err(already_closed_error(handle)); + } + if self.slots[slot_index].resource_type_key.as_ref() != Some(expected) { + return Err(resource_type_key_mismatch( + Some(handle), + self.slots[slot_index].resource_type_key.as_ref(), + expected, + )); + } + Ok(()) + } + + /// Validates the declaration key of a concrete resource type before any + /// handle access or host handler logic occurs. + pub fn validate_concrete_resource_type_key( + expected: &ResourceTypeKey, + ) -> ResourceResult<()> { + if T::resource_type_key().as_ref() == Some(expected) { + Ok(()) + } else { + Err(resource_type_key_mismatch( + None, + T::resource_type_key().as_ref(), + expected, + )) + } + } } impl Drop for ResourceTable { @@ -948,6 +1037,25 @@ fn type_mismatch(handle: ResourceHandle, expected: TypeId) -> ResourceError { .with_value(handle.raw()) } +fn resource_type_key_mismatch( + handle: Option, + actual: Option<&ResourceTypeKey>, + expected: &ResourceTypeKey, +) -> ResourceError { + let actual = actual.map_or("".to_string(), ToString::to_string); + let mut error = ResourceError::new( + ResourceErrorCode::ResourceTypeKeyMismatch, + "resource::table", + format!( + "resource type key does not match expected key '{expected}'; actual key is '{actual}'" + ), + ); + if let Some(handle) = handle { + error = error.with_value(handle.raw()); + } + error +} + fn not_closing_error(handle: ResourceHandle) -> ResourceError { ResourceError::new( ResourceErrorCode::ResourceNotClosing, @@ -1007,6 +1115,7 @@ mod tests { } /// A distinct inert type used to mint a mismatched `Resource`. + #[derive(Debug)] struct OtherRes; impl HostResource for OtherRes {} @@ -1038,6 +1147,44 @@ mod tests { table.get(&token).expect("real token unaffected"); } + #[test] + fn take_removes_the_value_once_and_preserves_typed_errors() { + let mut table = ResourceTable::new().expect("table"); + let (res, closes) = UnitRes::new(); + let token = table.push(res).unwrap(); + let handle = token.handle(); + + let wrong: Resource = Resource::from_handle(handle); + assert_eq!( + table.take::(wrong.handle()).unwrap_err().code(), + ResourceErrorCode::ResourceTypeMismatch + ); + assert_eq!( + table.len(), + 1, + "a wrong-type take must not consume the slot" + ); + + let taken: UnitRes = table.take(token.handle()).expect("take succeeds"); + assert_eq!(taken.0.load(Ordering::SeqCst), 0); + assert_eq!(table.len(), 0, "take removes the resource from the table"); + assert_eq!( + closes.load(Ordering::SeqCst), + 0, + "take transfers without closing" + ); + assert_eq!( + table.take::(token.handle()).unwrap_err().code(), + ResourceErrorCode::ResourceAlreadyClosed + ); + + let _replacement = table.push(UnitRes::new().0).expect("slot is reusable"); + assert_eq!( + table.take::(token.handle()).unwrap_err().code(), + ResourceErrorCode::ResourceStale + ); + } + #[test] fn begin_close_is_exact_once_and_stales_the_handle() { let mut table = ResourceTable::new().expect("table"); diff --git a/src/vm/run_context.rs b/src/vm/run_context.rs index b8dcde73..d15cca1e 100644 --- a/src/vm/run_context.rs +++ b/src/vm/run_context.rs @@ -1,18 +1,16 @@ //! Run-scoped execution context. //! -//! [`RunContext`] owns everything that belongs to one execution of a program: -//! fuel and epoch budgets, the interrupt mode, and the epoch counter handle. A -//! fresh logical run starts from a reset context; nothing here survives a reset -//! except the epoch handle identity (which is intentionally process-lifetime). -//! +//! [`RunContext`] owns everything that belongs to one execution of a +//! program: fuel and epoch budgets, the interrupt mode, the epoch counter +//! handle, and generic per-item invocation configuration. A fresh logical run +//! starts from a reset context; persistent policy such as event limits remains +//! attached to the VM until explicitly changed. //! The embedder-facing fuel/epoch APIs live on the VM facade (see //! `crate::vm::fuel` and `crate::vm::epoch`) and delegate here. Cancellation of //! pending host operations lives in the facade because it crosses into -//! [`HostRuntime`](super::host_runtime::HostRuntime) state. There is no per-run -//! input/event state here by design: this mechanical decomposition only moves -//! budgets and interruption state, and new runtime semantics (input/event -//! scopes, cancellation tokens) are intentionally left out of this commit. +//! [`HostRuntime`](super::host_runtime::HostRuntime) state. +use super::runtime::RuntimeContext; use crate::vm::VmError; use crate::vm::VmResult; use crate::vm::epoch::EpochHandle; @@ -42,6 +40,7 @@ impl InterruptMode { /// shared; one facade owns one context. Clone semantics: not `Clone` — a clone /// would duplicate budget state across runs. pub(crate) struct RunContext { + pub(crate) runtime_context: RuntimeContext, pub(crate) interrupt_mode: InterruptMode, pub(crate) fuel_remaining: u64, pub(crate) fuel_check_interval: u32, @@ -62,6 +61,7 @@ impl RunContext { let epoch_handle = EpochHandle::default(); let epoch_counter_ptr = epoch_handle.as_ptr() as usize; Self { + runtime_context: RuntimeContext::default(), interrupt_mode: InterruptMode::None, fuel_remaining: 0, fuel_check_interval: 1, @@ -82,6 +82,15 @@ impl RunContext { self.clear_epoch_deadline_internal(); } + pub(crate) fn is_reusable(&self) -> bool { + self.interrupt_mode == InterruptMode::None + && self.fuel_remaining == 0 + && self.epoch_deadline == 0 + && self.epoch_deadline_delta == 0 + && !self.epoch_rearm_pending + && self.fuel_ops_until_check == self.fuel_check_interval.max(1) + } + pub(crate) fn reset_interrupt_countdown(&mut self) { self.fuel_ops_until_check = self.fuel_check_interval.max(1); } diff --git a/src/vm/runtime.rs b/src/vm/runtime.rs new file mode 100644 index 00000000..2bfce992 --- /dev/null +++ b/src/vm/runtime.rs @@ -0,0 +1,388 @@ +//! Adapter-independent runtime values used by the VM execution boundary. +//! +//! This module owns invocation-stream limits, event payload validation, and +//! structured runtime errors. Concrete builtin adapters may re-export these +//! types, but the VM does not depend on any adapter module for them. + +use std::fmt; + +use crate::vm::Value; + +/// Result type used by generic runtime support modules. +pub type RuntimeResult = Result; + +/// Stable machine-readable categories for runtime capability failures. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeErrorCode { + InvalidConfiguration, + EventPayloadTooLarge, + EventDepthExceeded, + ResourceLimitExceeded, + InvalidResourceHandle, + ResourceHandleWrongTable, + ResourceTypeMismatch, + ResourceStale, + ResourceAlreadyClosed, + ResourceIdExhausted, + ResourceCleanupFailed, + OperationLimitExceeded, + OperationNotFound, + OperationAlreadyFinished, + OperationCancelled, + OperationFailed, + OperationIdExhausted, + OperationCleanupFailed, +} + +impl RuntimeErrorCode { + pub const fn as_str(self) -> &'static str { + match self { + Self::InvalidConfiguration => "invalid_configuration", + Self::EventPayloadTooLarge => "event_payload_too_large", + Self::EventDepthExceeded => "event_depth_exceeded", + Self::ResourceLimitExceeded => "resource_limit_exceeded", + Self::InvalidResourceHandle => "invalid_resource_handle", + Self::ResourceHandleWrongTable => "resource_handle_wrong_table", + Self::ResourceTypeMismatch => "resource_type_mismatch", + Self::ResourceStale => "resource_stale", + Self::ResourceAlreadyClosed => "resource_already_closed", + Self::ResourceIdExhausted => "resource_id_exhausted", + Self::ResourceCleanupFailed => "resource_cleanup_failed", + Self::OperationLimitExceeded => "operation_limit_exceeded", + Self::OperationNotFound => "operation_not_found", + Self::OperationAlreadyFinished => "operation_already_finished", + Self::OperationCancelled => "operation_cancelled", + Self::OperationFailed => "operation_failed", + Self::OperationIdExhausted => "operation_id_exhausted", + Self::OperationCleanupFailed => "operation_cleanup_failed", + } + } +} + +/// Structured error returned by generic runtime support modules. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeError { + code: RuntimeErrorCode, + operation: &'static str, + message: String, + limit: Option, + value: Option, +} + +#[allow(dead_code)] +impl RuntimeError { + pub fn new( + code: RuntimeErrorCode, + operation: &'static str, + message: impl Into, + ) -> Self { + Self { + code, + operation, + message: message.into(), + limit: None, + value: None, + } + } + + pub fn code(&self) -> RuntimeErrorCode { + self.code + } + + pub fn operation(&self) -> &'static str { + self.operation + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn limit(&self) -> Option { + self.limit + } + + pub fn value(&self) -> Option { + self.value + } + + pub fn with_limit(mut self, limit: usize) -> Self { + self.limit = Some(limit); + self + } + + pub fn with_value(mut self, value: u64) -> Self { + self.value = Some(value); + self + } +} + +impl fmt::Display for RuntimeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "runtime error [{}] in {}: {}", + self.code.as_str(), + self.operation, + self.message + )?; + if let Some(limit) = self.limit { + write!(formatter, " (limit: {limit})")?; + } + if let Some(value) = self.value { + write!(formatter, " (value: {value})")?; + } + Ok(()) + } +} + +impl std::error::Error for RuntimeError {} + +pub const DEFAULT_MAX_EVENT_PAYLOAD_BYTES: usize = 64 * 1024; +pub const DEFAULT_MAX_EVENT_DEPTH: usize = 64; + +/// Per-item bounds applied to one `stream::emit(value)` call. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EventLimits { + max_payload_bytes: usize, + max_depth: usize, +} + +#[allow(dead_code)] +impl EventLimits { + pub fn new(max_payload_bytes: usize, max_depth: usize) -> RuntimeResult { + if max_payload_bytes == 0 || max_depth == 0 { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "stream::emit", + "event payload and depth limits must be positive", + )); + } + Ok(Self { + max_payload_bytes, + max_depth, + }) + } + + pub const fn max_payload_bytes(self) -> usize { + self.max_payload_bytes + } + + pub const fn max_depth(self) -> usize { + self.max_depth + } +} + +impl Default for EventLimits { + fn default() -> Self { + Self { + max_payload_bytes: DEFAULT_MAX_EVENT_PAYLOAD_BYTES, + max_depth: DEFAULT_MAX_EVENT_DEPTH, + } + } +} + +/// An event value whose per-item bound has been validated. +#[derive(Clone, Debug, PartialEq)] +pub struct EventPayload { + value: Value, + size_bytes: usize, +} + +impl EventPayload { + pub fn try_new(value: Value, limits: EventLimits) -> RuntimeResult { + let size_bytes = estimate_value_size(&value, limits)?; + Ok(Self { value, size_bytes }) + } + + #[allow(dead_code)] + pub fn size_bytes(&self) -> usize { + self.size_bytes + } + + pub fn into_value(self) -> Value { + self.value + } +} + +/// Estimates the bounded representation size of a value. +pub fn estimate_value_size(value: &Value, limits: EventLimits) -> RuntimeResult { + measure_value(value, 0, limits) +} + +fn measure_value(value: &Value, depth: usize, limits: EventLimits) -> RuntimeResult { + if depth > limits.max_depth { + return Err(RuntimeError::new( + RuntimeErrorCode::EventDepthExceeded, + "stream::emit", + "event payload nesting exceeds the configured bound", + ) + .with_limit(limits.max_depth) + .with_value(depth as u64)); + } + + let size = match value { + Value::Null | Value::Bool(_) => 1, + Value::Int(_) | Value::Float(_) => 9, + Value::String(text) => 1usize.saturating_add(text.len()), + Value::Bytes(bytes) => 1usize.saturating_add(bytes.len()), + Value::Callable(_) => 17, + Value::Array(values) => { + let mut size = 5usize; + for child in values.iter() { + size = checked_payload_add(size, measure_value(child, depth + 1, limits)?, limits)?; + } + size + } + Value::Map(entries) => { + let mut size = 5usize; + for (key, child) in entries.iter() { + size = checked_payload_add(size, measure_value(key, depth + 1, limits)?, limits)?; + size = checked_payload_add(size, measure_value(child, depth + 1, limits)?, limits)?; + } + size + } + }; + + if size > limits.max_payload_bytes { + return Err(RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "stream::emit", + "event payload exceeds the configured byte bound", + ) + .with_limit(limits.max_payload_bytes) + .with_value(size as u64)); + } + Ok(size) +} + +fn checked_payload_add( + current: usize, + additional: usize, + limits: EventLimits, +) -> RuntimeResult { + let total = current.checked_add(additional).ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "stream::emit", + "event payload size overflowed", + ) + .with_limit(limits.max_payload_bytes) + })?; + if total > limits.max_payload_bytes { + return Err(RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "stream::emit", + "event payload exceeds the configured byte bound", + ) + .with_limit(limits.max_payload_bytes) + .with_value(total as u64)); + } + Ok(total) +} + +/// Stable source name used by the stream event host function. +pub const STREAM_EMIT_NAME: &str = "stream::emit"; + +/// Configuration for one VM/run-scoped invocation stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeContextConfig { + event_limits: EventLimits, +} + +impl RuntimeContextConfig { + pub const fn new(event_limits: EventLimits) -> Self { + Self { event_limits } + } + + pub const fn event_limits(self) -> EventLimits { + self.event_limits + } +} + +impl Default for RuntimeContextConfig { + fn default() -> Self { + Self::new(EventLimits::default()) + } +} + +/// Run-scoped invocation stream configuration. +pub struct RuntimeContext { + event_limits: EventLimits, +} + +#[allow(dead_code)] +impl RuntimeContext { + pub fn with_config(config: RuntimeContextConfig) -> RuntimeResult { + Ok(Self { + event_limits: config.event_limits(), + }) + } + + pub fn config(&self) -> RuntimeContextConfig { + RuntimeContextConfig::new(self.event_limits) + } + + pub fn event_limits(&self) -> EventLimits { + self.event_limits + } +} + +impl Default for RuntimeContext { + fn default() -> Self { + Self::with_config(RuntimeContextConfig::default()) + .expect("default runtime context configuration should be valid") + } +} + +#[cfg(test)] +mod tests { + use super::{ + EventLimits, EventPayload, RuntimeContext, RuntimeContextConfig, RuntimeError, + RuntimeErrorCode, STREAM_EMIT_NAME, + }; + use crate::vm::Value; + + #[test] + fn runtime_context_and_error_api_are_neutral() { + assert_eq!(STREAM_EMIT_NAME, "stream::emit"); + assert!(std::mem::size_of::() > 0); + let error = RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + STREAM_EMIT_NAME, + "payload exceeds limit", + ) + .with_limit(32) + .with_value(64); + assert_eq!(error.code(), RuntimeErrorCode::EventPayloadTooLarge); + assert_eq!(error.limit(), Some(32)); + assert_eq!(error.value(), Some(64)); + assert!(error.to_string().contains("event_payload_too_large")); + } + + #[test] + fn per_item_event_limits_validate_payload_and_depth() { + let limits = EventLimits::new(32, 4).expect("limits should be valid"); + let context = RuntimeContext::with_config(RuntimeContextConfig::new(limits)) + .expect("context should be constructible"); + assert_eq!(context.event_limits(), limits); + let payload = + EventPayload::try_new(Value::string("event"), limits).expect("payload should fit"); + assert!(payload.size_bytes() >= 5); + assert_eq!(payload.into_value(), Value::string("event")); + } + + #[test] + fn oversized_or_too_deep_values_are_rejected_before_placement() { + let limits = EventLimits::new(8, 2).expect("limits should be valid"); + let too_large = EventPayload::try_new(Value::string("payload-too-large"), limits) + .expect_err("oversized event should be rejected"); + assert_eq!(too_large.code(), RuntimeErrorCode::EventPayloadTooLarge); + let too_deep = EventPayload::try_new( + Value::array(vec![Value::array(vec![Value::array(vec![Value::Int(1)])])]), + limits, + ) + .expect_err("too-deep event should be rejected"); + assert_eq!(too_deep.code(), RuntimeErrorCode::EventDepthExceeded); + } +} diff --git a/src/vm/standard_composition.rs b/src/vm/standard_composition.rs new file mode 100644 index 00000000..dbef6a5b --- /dev/null +++ b/src/vm/standard_composition.rs @@ -0,0 +1,127 @@ +//! Generic boundary for the optional standard runtime surface. +//! +//! The VM owns the trait and its opaque call outcomes. The standard runtime +//! implements it from the builtin registration area; alternate embeddings can +//! provide another composition without importing that area into `src/vm`. + +use std::sync::Arc; + +use crate::bytecode::{HostImport, SharedArray, SharedMap}; +use crate::{BuiltinFunction, Value}; + +use super::{CallOutcome, HostFunctionRegistry, Vm, VmError, VmResult}; + +/// Runtime surface operations that the VM may request without knowing their +/// concrete implementation module. +pub trait StandardSurfaceComposition: Send + Sync { + /// Reports whether this composition owns a standard host import. + fn import_in_standard(&self, import: &HostImport) -> bool; + + /// Stages standard host functions needed by the supplied imports. + fn ensure_surfaces( + &self, + imports: &[HostImport], + registry: &mut HostFunctionRegistry, + ) -> VmResult; + + /// Builds a fresh registry containing this composition's standard host + /// functions. + fn build_default_registry(&self) -> VmResult; + + /// Binds one standard host function by source name. + fn bind_default_name(&self, vm: &mut Vm, name: &str) -> bool; + + /// Dispatches a catalog builtin. The default keeps custom compositions + /// source-compatible while reporting that no builtin dispatcher is present. + fn execute_builtin_call( + &self, + _vm: &mut Vm, + _builtin: BuiltinFunction, + _args: &mut [Value], + ) -> VmResult { + Err(VmError::HostError( + "standard surface has no builtin dispatcher".to_string(), + )) + } + + /// Optional fast paths used by the portable interpreter and native bridge. + fn string_contains(&self, _text: &str, _needle: &str) -> Option { + None + } + + fn string_replace_literal( + &self, + _text: &str, + _needle: &str, + _replacement: &str, + ) -> Option { + None + } + + fn string_lower_ascii(&self, _text: &str) -> Option { + None + } + + fn string_split_literal(&self, _text: &str, _delimiter: &str) -> Option> { + None + } + + fn value_to_string(&self, _value: &Value) -> Option { + None + } + + fn regex_match(&self, _vm: &mut Vm, _pattern: &str, _text: &str) -> VmResult { + Err(VmError::HostError( + "standard surface has no regex matcher".to_string(), + )) + } + + fn regex_replace( + &self, + _vm: &mut Vm, + _pattern: &str, + _text: &str, + _replacement: &str, + ) -> VmResult { + Err(VmError::HostError( + "standard surface has no regex replacer".to_string(), + )) + } + + fn ensure_supported_map_key(&self, _key: &Value) -> VmResult<()> { + Err(VmError::HostError( + "standard surface has no map-key validator".to_string(), + )) + } + + fn set_owned(&self, _container: Value, _key: Value, _value: Value) -> VmResult { + Err(VmError::HostError( + "standard surface has no container setter".to_string(), + )) + } + + fn set_map_shared(&self, _entries: SharedMap, _key: Value, _value: Value) -> Option { + None + } + + fn array_push_shared(&self, _items: SharedArray, _value: Value) -> Option { + None + } +} + +/// Shared per-runtime handle wrapping a caller-provided composition. +#[derive(Clone)] +pub struct StandardCompositionHandle(pub Arc); + +impl StandardCompositionHandle { + /// Installs this composition on a registry's standard composition slot. + pub fn install(&self, registry: &mut HostFunctionRegistry) { + registry.set_standard_composition(Arc::clone(&self.0)); + } +} + +/// Returns a fresh standard-surface handle for callers that want the default +/// runtime composition. +pub fn standard_composition() -> Arc { + crate::standard_composition() +} diff --git a/src/vm/standard_ops.rs b/src/vm/standard_ops.rs new file mode 100644 index 00000000..abb30919 --- /dev/null +++ b/src/vm/standard_ops.rs @@ -0,0 +1,160 @@ +//! Pure VM operations shared by the interpreter and native bridge. +//! +//! These operations describe language values only. They intentionally do not +//! know about builtin registration, host policies, or a concrete adapter. + +use std::sync::Arc; + +use crate::Value; +use crate::bytecode::{CallableKind, SharedArray, SharedMap}; +use crate::vm::{VmError, VmResult}; + +pub(crate) fn string_contains(text: &str, needle: &str) -> bool { + text.contains(needle) +} + +pub(crate) fn string_replace_literal(text: &str, needle: &str, replacement: &str) -> String { + if needle.is_empty() { + return text.to_string(); + } + text.replace(needle, replacement) +} + +pub(crate) fn string_lower_ascii(text: &str) -> String { + let mut out = text.as_bytes().to_vec(); + for byte in &mut out { + if byte.is_ascii_uppercase() { + *byte = byte.to_ascii_lowercase(); + } + } + String::from_utf8(out).expect("ASCII-only byte changes preserve UTF-8") +} + +pub(crate) fn string_split_literal(text: &str, delimiter: &str) -> Vec { + if delimiter.is_empty() { + return vec![Value::string(text.to_string())]; + } + text.split(delimiter) + .map(|part| Value::string(part.to_string())) + .collect() +} + +pub(crate) fn value_to_string(value: &Value) -> String { + render_value_for_display(value) +} + +fn render_value_for_display(value: &Value) -> String { + match value { + Value::Null => "null".to_string(), + Value::Int(v) => v.to_string(), + Value::Float(v) => v.to_string(), + Value::Bool(v) => v.to_string(), + Value::String(v) => v.as_str().to_string(), + Value::Bytes(v) => render_bytes_for_display(v.as_ref()), + Value::Array(values) => { + let parts = values + .iter() + .map(render_value_for_display) + .collect::>() + .join(", "); + format!("[{parts}]") + } + Value::Map(entries) => { + let parts = entries + .iter() + .map(|(key, value)| { + format!( + "{}: {}", + render_value_for_display(key), + render_value_for_display(value) + ) + }) + .collect::>() + .join(", "); + format!("{{{parts}}}") + } + Value::Callable(callable) => match callable.kind { + CallableKind::FunctionItem => format!("", callable.prototype_id), + CallableKind::Closure => format!("", callable.prototype_id), + CallableKind::HostFunction => format!("", callable.prototype_id), + }, + } +} + +fn render_bytes_for_display(bytes: &[u8]) -> String { + let preview_len = bytes.len().min(16); + let mut preview = String::with_capacity(preview_len * 2); + for byte in &bytes[..preview_len] { + preview.push(hex_nibble(byte >> 4)); + preview.push(hex_nibble(byte & 0x0F)); + } + if bytes.len() > preview_len { + format!("bytes[len={} hex={}..]", bytes.len(), preview) + } else { + format!("bytes[len={} hex={}]", bytes.len(), preview) + } +} + +fn hex_nibble(value: u8) -> char { + match value { + 0..=9 => char::from(b'0' + value), + 10..=15 => char::from(b'a' + (value - 10)), + _ => unreachable!("hex nibble out of range"), + } +} + +pub(crate) fn ensure_supported_map_key(key: &Value) -> VmResult<()> { + if matches!(key, Value::Callable(_)) { + return Err(VmError::HostError( + "callable values are not supported as map keys".to_string(), + )); + } + Ok(()) +} + +fn set_array_shared(mut items: SharedArray, index: i64, value: Value) -> VmResult { + let items_mut = Arc::make_mut(&mut items); + if index < 0 { + return Err(VmError::HostError( + "array index must be non-negative".to_string(), + )); + } + let index = usize::try_from(index) + .map_err(|_| VmError::HostError("array index overflow".to_string()))?; + if index < items_mut.len() { + items_mut[index] = value; + } else if index == items_mut.len() { + items_mut.push(value); + } else { + return Err(VmError::HostError(format!( + "array index {index} out of bounds" + ))); + } + Ok(items) +} + +pub(crate) fn set_owned(container: Value, key: Value, value: Value) -> VmResult { + match container { + Value::Array(values) => set_array_shared(values, key.as_int()?, value).map(Value::Array), + Value::Map(entries) => { + ensure_supported_map_key(&key)?; + Ok(Value::Map(set_map_shared(entries, key, value))) + } + _ => Err(VmError::TypeMismatch("array/map")), + } +} + +pub(crate) fn set_map_shared(mut entries: SharedMap, key: Value, value: Value) -> SharedMap { + let entries_mut = Arc::make_mut(&mut entries); + if matches!(value, Value::Null) { + entries_mut.remove(&key); + } else { + entries_mut.insert(key, value); + } + entries +} + +pub(crate) fn array_push_shared(mut items: SharedArray, value: Value) -> SharedArray { + Arc::make_mut(&mut items).push(value); + items +} diff --git a/src/vm/store.rs b/src/vm/store.rs index 89a4b76e..7e9c6579 100644 --- a/src/vm/store.rs +++ b/src/vm/store.rs @@ -5,6 +5,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::Value; use crate::compiler::TypeSchema; +use super::execution_scope::ExecutionScopeError; use super::{EpochCheckpoint, EpochHandle, FuelCheckpoint, Vm, VmError, VmResult, VmStatus}; /// Lightweight Wasmtime-style store wrapper for VM state and host context data. @@ -338,6 +339,12 @@ impl Store { Args: ScriptArgs, Ret: ScriptResult, { + if let Some(error) = self.vm.scope_reset_error().cloned() { + return Err(VmError::ExecutionScope(error)); + } + if self.vm.scope_reset_pending() { + return Err(VmError::ExecutionScope(ExecutionScopeError::ScopeClosing)); + } if !self.callback_registry.0.load(Ordering::Acquire) { self.install_callback_registry(); } @@ -428,9 +435,36 @@ impl Store { Ok(()) } - pub fn reset_for_reuse(&mut self) { - self.vm.reset_for_reuse(); - self.install_callback_registry(); + pub fn reset_for_reuse(&mut self) -> VmResult<()> { + self.vm.reset_for_reuse()?; + if !self.vm.scope_reset_pending() { + self.install_callback_registry(); + } + Ok(()) + } + + /// Polls a pending reset and publishes a callback registry only after the + /// VM's old execution scope has reached clean quiescence. + pub fn poll_reset_for_reuse( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let was_pending = self.vm.scope_reset_pending(); + match self.vm.poll_reset_for_reuse(cx) { + std::task::Poll::Pending => std::task::Poll::Pending, + std::task::Poll::Ready(Ok(())) => { + if was_pending && !self.vm.scope_reset_pending() { + self.install_callback_registry(); + } + std::task::Poll::Ready(Ok(())) + } + std::task::Poll::Ready(Err(error)) => std::task::Poll::Ready(Err(error)), + } + } + + /// Whether this store is safe to return to a VM reuse pool. + pub fn is_reusable(&self) -> bool { + self.vm.is_reusable() } pub fn replace_vm(&mut self, mut vm: Vm) { diff --git a/src/vm/tests.rs b/src/vm/tests.rs index bc26d402..c0a05d41 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -1,14 +1,271 @@ use super::*; -use crate::builtins::BuiltinFunction; +use crate::BuiltinFunction; use crate::bytecode::TypeMap; +use crate::host_api::{ + HostApiBuilder, HostFunctionSchema, HostImportSchema, HostTypeSchema, ResourceTypeKey, + ResourceTypeSchema, +}; +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 std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; +use std::task::{Context, Poll, Waker}; fn native_cache_test_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } +#[test] +fn program_cache_key_includes_full_host_import_schema() { + let function = + HostFunctionSchema::with_return("cache::schema", Vec::new(), HostTypeSchema::Int); + let mut builder = HostApiBuilder::new(); + builder.function(function.clone()); + let catalog = builder.build().expect("catalog"); + let schema = HostImportSchema::from_function(&catalog, &function); + + let import = HostImport { + name: schema.name.clone(), + arity: 0, + return_type: ValueType::Int, + }; + let first = Program::with_imports_and_debug( + Vec::new(), + vec![OpCode::Ret as u8], + vec![import.clone()], + None, + ) + .with_host_import_schemas(vec![schema.clone()]) + .expect("first schema"); + let mut changed_schema = schema; + changed_schema.return_type = HostTypeSchema::String; + let second = + Program::with_imports_and_debug(Vec::new(), vec![OpCode::Ret as u8], vec![import], None) + .with_host_import_schemas(vec![changed_schema]) + .expect("second schema"); + + assert_ne!( + compute_program_cache_key(&first), + compute_program_cache_key(&second), + "host schema changes must invalidate program-semantic caches" + ); +} + +#[test] +fn legacy_and_marker_only_schema_absence_share_cache_identity() { + let import = HostImport { + name: "legacy::cache".to_string(), + arity: 0, + return_type: ValueType::Unknown, + }; + let plain = + Program::with_imports_and_debug(Vec::new(), vec![OpCode::Ret as u8], vec![import], None); + let marked = decode_program(&encode_program(&plain).expect("marker-only encoding")) + .expect("marker-only decoding"); + assert_eq!( + compute_program_cache_key(&plain), + compute_program_cache_key(&marked) + ); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn builtin_pending_completion_uses_declared_return_type() { + struct PendingBridge; + + impl HostAsyncBridge for PendingBridge { + fn submit_op(&mut self, _op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + Ok(()) + } + + fn poll_op( + &mut self, + _op_id: HostOpId, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } + } + + let builtin = BuiltinFunction::from_namespaced_name("io::exists") + .expect("io::exists builtin should be available"); + let mut bytecode = BytecodeBuilder::new(); + bytecode.ldc(0); + bytecode.call(builtin.call_index(), 1); + bytecode.ret(); + + let mut vm = Vm::new(Program::new(vec![Value::string(".")], bytecode.finish())); + vm.set_async_bridge(Box::new(PendingBridge)) + .expect("pending bridge should install"); + let VmStatus::Waiting(op_id) = vm.run().expect("builtin should enter pending") else { + panic!("io::exists should suspend"); + }; + let error = vm + .complete_host_op(op_id, CallReturn::one(Value::string("wrong"))) + .expect_err("pending completion must reject the wrong type"); + assert!(matches!(error, VmError::TypeMismatch("bool")), "{error:?}"); + + let mut bytecode = BytecodeBuilder::new(); + bytecode.ldc(0); + bytecode.call(builtin.call_index(), 1); + bytecode.ret(); + let mut vm = Vm::new(Program::new(vec![Value::string(".")], bytecode.finish())); + vm.set_async_bridge(Box::new(PendingBridge)) + .expect("pending bridge should install"); + let VmStatus::Waiting(op_id) = vm.run().expect("builtin should enter pending") else { + panic!("io::exists should suspend"); + }; + vm.complete_host_op(op_id, CallReturn::one(Value::Bool(true))) + .expect("matching pending completion should be accepted"); + assert_eq!( + vm.resume().expect("resume after completion"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Bool(true)]); +} + +#[test] +fn pending_completion_validates_scalar_resource_and_collection_schemas() { + let make_schema = |name: &str, return_type: HostTypeSchema| { + let function = HostFunctionSchema::with_return(name, Vec::new(), return_type); + let mut builder = HostApiBuilder::new(); + builder.function(function.clone()); + let catalog = builder.build().expect("catalog"); + HostImportSchema::from_function(&catalog, &function) + }; + + let cases = [ + ( + make_schema("pending::string", HostTypeSchema::String), + Value::Int(1), + "string", + ), + ( + make_schema( + "pending::array", + HostTypeSchema::Array(Box::new(HostTypeSchema::Int)), + ), + Value::array(vec![Value::string("wrong")]), + "int", + ), + ]; + for (schema, wrong, expected) in cases { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_waiting_host_op_with_return( + 77, + WaitingHostOpSource::HostBridge, + None, + Some(&schema), + ) + .expect("waiting state"); + let error = vm + .complete_waiting_host_op(77, CallReturn::one(wrong)) + .expect_err("wrong pending completion should fail"); + assert!(matches!(error, VmError::TypeMismatch(actual) if actual == expected)); + } + + let resource = ResourceTypeKey::new("pending.resource").expect("resource key"); + let function = HostFunctionSchema::with_return( + "pending::resource", + Vec::new(), + HostTypeSchema::Resource(resource.clone()), + ); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(resource, "pending resource")); + builder.function(function.clone()); + let catalog = builder.build().expect("resource catalog"); + let schema = HostImportSchema::from_function(&catalog, &function); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_waiting_host_op_with_return(78, WaitingHostOpSource::HostBridge, None, Some(&schema)) + .expect("waiting state"); + let error = vm + .complete_waiting_host_op(78, CallReturn::one(Value::string("wrong"))) + .expect_err("wrong resource completion should fail"); + assert!(matches!(error, VmError::TypeMismatch("resource"))); + + let schema = make_schema( + "pending::array_ok", + HostTypeSchema::Array(Box::new(HostTypeSchema::Int)), + ); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_waiting_host_op_with_return( + 79, + WaitingHostOpSource::ScopedOperation, + None, + Some(&schema), + ) + .expect("waiting state"); + vm.complete_waiting_host_op( + 79, + CallReturn::one(Value::array(vec![Value::Int(1), Value::Int(2)])), + ) + .expect("matching collection completion should pass"); + assert_eq!( + vm.stack(), + &[Value::array(vec![Value::Int(1), Value::Int(2)])] + ); +} + +#[test] +fn pending_callable_completion_uses_authoritative_prototype_schema() { + let expected_callable = HostTypeSchema::Callable { + params: vec![HostTypeSchema::Int], + result: Box::new(HostTypeSchema::String), + }; + let function = + HostFunctionSchema::with_return("pending::callable", Vec::new(), expected_callable); + let mut builder = HostApiBuilder::new(); + builder.function(function.clone()); + let catalog = builder.build().expect("callable catalog"); + let schema = HostImportSchema::from_function(&catalog, &function); + let callable = Value::Callable(Arc::new(crate::CallableValue { + prototype_id: 0, + kind: crate::CallableKind::FunctionItem, + env: None, + })); + + let complete = |result: crate::compiler::TypeSchema| { + let program = Program::new(Vec::new(), vec![OpCode::Ret as u8]).with_callable_metadata( + Vec::new(), + vec![crate::CallablePrototype { + kind: crate::CallableKind::FunctionItem, + target: crate::CallableTarget::ScriptFunction(0), + arity: 1, + frame_local_count: 1, + parameter_slots: vec![0], + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: Some(crate::compiler::TypeSchema::Callable { + params: vec![crate::compiler::TypeSchema::Int], + result: Box::new(result), + }), + }], + Vec::new(), + Vec::new(), + ); + let mut vm = Vm::new(program); + vm.set_waiting_host_op_with_return( + 80, + WaitingHostOpSource::HostBridge, + None, + Some(&schema), + )?; + vm.complete_waiting_host_op(80, CallReturn::one(callable.clone())) + }; + + complete(crate::compiler::TypeSchema::String).expect("matching callable should pass"); + assert!(matches!( + complete(crate::compiler::TypeSchema::Bool), + Err(VmError::TypeMismatch("callable")) + )); +} + #[test] fn root_ret_completes_explicit_halt_frame() { let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); @@ -22,7 +279,7 @@ fn root_ret_completes_explicit_halt_frame() { assert!(vm.instance.execution_frames.is_empty()); assert!(vm.stack().is_empty()); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.instance.execution_frames.len(), 1); assert_eq!(vm.stack(), &[]); } @@ -31,10 +288,257 @@ fn root_ret_completes_explicit_halt_frame() { fn reset_for_reuse_keeps_host_operation_ids_monotonic() { let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); assert_eq!(vm.allocate_host_op_id(), 1); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.allocate_host_op_id(), 2); } +#[test] +fn invocation_callable_ownership_walk_handles_shared_and_cyclic_captures() { + let program = Program::new(Vec::new(), vec![OpCode::Ret as u8]).with_callable_metadata( + vec![crate::ScriptFunction { + entry_ip: 0, + end_ip: 1, + }], + vec![crate::CallablePrototype { + kind: crate::CallableKind::FunctionItem, + target: crate::CallableTarget::ScriptFunction(0), + arity: 1, + frame_local_count: 1, + parameter_slots: vec![0], + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }], + vec![crate::FunctionRegion { + start_ip: 0, + end_ip: 1, + prototype_id: Some(0), + }], + Vec::new(), + ); + let mut vm = Vm::new(program); + let entry = Arc::new(crate::CallableValue { + prototype_id: 0, + kind: crate::CallableKind::FunctionItem, + env: None, + }); + vm.instance.owned_callables.push(Arc::downgrade(&entry)); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + + let cell = Arc::new(Mutex::new(Value::Null)); + let environment = Arc::new(crate::CallableEnvironment { + cells: Mutex::new(vec![Arc::clone(&cell)]), + }); + let cyclic = Arc::new(crate::CallableValue { + prototype_id: 0, + kind: crate::CallableKind::Closure, + env: Some(environment), + }); + *cell.lock().expect("capture cell lock") = Value::Callable(Arc::clone(&cyclic)); + vm.instance.owned_callables.push(Arc::downgrade(&cyclic)); + + let shared = Value::array(vec![Value::Callable(cyclic)]); + let args = vec![Value::array(vec![shared.clone(), shared])]; + let mut invocation = vm + .start_invocation(Value::Callable(Arc::clone(&entry)), args) + .expect("shared cyclic callable graph should be traversable"); + assert!(matches!( + invocation + .poll_next() + .expect("invocation poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Null)))) + )); + drop(invocation); + + let mut foreign_vm = Vm::new(vm.program().clone()); + let foreign = Arc::new(crate::CallableValue { + prototype_id: 0, + kind: crate::CallableKind::FunctionItem, + env: None, + }); + foreign_vm + .instance + .owned_callables + .push(Arc::downgrade(&foreign)); + let captured_cell = Arc::new(Mutex::new(Value::Callable(foreign))); + let captured_environment = Arc::new(crate::CallableEnvironment { + cells: Mutex::new(vec![captured_cell]), + }); + let captured = Arc::new(crate::CallableValue { + prototype_id: 0, + kind: crate::CallableKind::Closure, + env: Some(captured_environment), + }); + vm.instance.owned_callables.push(Arc::downgrade(&captured)); + let before_ip = vm.ip(); + let before_locals = vm.locals().to_vec(); + assert!(matches!( + vm.start_invocation( + Value::Callable(entry), + vec![Value::array(vec![Value::Callable(captured)])] + ), + Err(VmError::InvalidCallable) + )); + assert_eq!(vm.ip(), before_ip); + assert_eq!(vm.locals(), before_locals.as_slice()); + assert!(vm.execution_frames().is_empty()); + assert!(vm.stack().is_empty()); + assert_eq!(vm.call_depth(), 0); + drop(foreign_vm); +} + +struct ReserveBeforeSubmitBridge { + submissions: Arc>>, + fail_submission: Arc, +} + +impl HostAsyncBridge for ReserveBeforeSubmitBridge { + fn submit_op(&mut self, op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + self.submissions + .lock() + .expect("submission lock") + .push(op_id); + if self.fail_submission.load(Ordering::SeqCst) { + Err(VmError::HostError("injected submit failure".to_string())) + } else { + Ok(()) + } + } + + fn poll_op(&mut self, _op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } +} + +fn empty_host_future() -> HostFuture { + Box::pin(async { Ok(HostFutureOutput::returning(CallReturn::none())) }) +} + +#[test] +fn submitted_bridge_operation_reserves_before_submit_and_rolls_back_on_failure() { + let submissions = Arc::new(Mutex::new(Vec::new())); + let fail_submission = Arc::new(AtomicBool::new(false)); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(ReserveBeforeSubmitBridge { + submissions: Arc::clone(&submissions), + fail_submission: Arc::clone(&fail_submission), + })) + .expect("bridge should install"); + + vm.host.next_host_op_id = 17; + vm.host + .track_bridge_host_op(17) + .expect("existing operation should be tracked"); + let collision = vm + .submit_host_future(empty_host_future()) + .expect_err("colliding operation id should be rejected"); + assert!( + collision + .to_string() + .contains("bridge host op 17 is already tracked") + ); + assert!( + submissions.lock().expect("submission lock").is_empty(), + "the bridge must not receive a colliding operation" + ); + + vm.host + .complete_bridge_operation(17, HostAsyncOpTerminal::Completed) + .expect("existing operation should be retired"); + vm.host.next_host_op_id = HostOpId::MAX; + let wrapped = vm + .submit_host_future(empty_host_future()) + .expect_err("wrapping operation id should be rejected"); + assert!(wrapped.to_string().contains("operation id space exhausted")); + assert!( + submissions.lock().expect("submission lock").is_empty(), + "the bridge must not receive an operation after id exhaustion" + ); + + vm.host.next_host_op_id = 23; + fail_submission.store(true, Ordering::SeqCst); + let failed = vm + .submit_host_future(empty_host_future()) + .expect_err("injected submit failure should propagate"); + assert!(failed.to_string().contains("injected submit failure")); + assert_eq!(vm.host.next_host_op_id, 23); + assert!(!vm.host.submitted_host_ops.contains(&23)); + assert!(!vm.host.is_bridge_operation_tracked(23)); + + fail_submission.store(false, Ordering::SeqCst); + assert_eq!( + vm.submit_host_future(empty_host_future()) + .expect("rolled back operation id should be reusable"), + CallOutcome::Pending(23) + ); + assert_eq!( + submissions.lock().expect("submission lock").as_slice(), + &[23, 23] + ); +} + +struct ReasonAwareCleanupOnlyBridge { + cleanups: Arc>>, +} + +impl HostAsyncBridge for ReasonAwareCleanupOnlyBridge { + fn submit_op(&mut self, _op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + Ok(()) + } + + fn poll_op(&mut self, _op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + + fn cancel_op_with_reason(&mut self, op_id: HostOpId, reason: OperationCancelReason) { + self.cleanups + .lock() + .expect("cleanup lock") + .push((op_id, reason)); + } +} + +#[test] +fn default_bridge_cleanup_routes_every_terminal_state_through_reason_aware_hook() { + let cleanups = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(ReasonAwareCleanupOnlyBridge { + cleanups: Arc::clone(&cleanups), + })) + .expect("bridge should install"); + + let mut op_ids = Vec::new(); + for _ in 0..3 { + let CallOutcome::Pending(op_id) = vm + .submit_host_future(empty_host_future()) + .expect("submission should succeed") + else { + panic!("submission should suspend"); + }; + op_ids.push(op_id); + } + for (op_id, terminal) in op_ids.iter().copied().zip([ + HostAsyncOpTerminal::Completed, + HostAsyncOpTerminal::Failed, + HostAsyncOpTerminal::Cancelled, + ]) { + vm.host + .complete_bridge_operation(op_id, terminal) + .expect("terminal cleanup should succeed"); + } + + assert_eq!( + *cleanups.lock().expect("cleanup lock"), + op_ids + .into_iter() + .map(|op_id| (op_id, OperationCancelReason::Requested)) + .collect::>() + ); + assert!(vm.host.submitted_host_ops.is_empty()); +} + #[test] fn shared_capture_cell_rejects_callable_ownership_cycle() { let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8]).with_local_count(1)); @@ -268,7 +772,7 @@ fn host_can_invoke_exported_callable_and_reset_rebinds_program_owned_value() { Err(VmError::InvalidFrameState("vm is shut down")) )); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().expect("reset root should halt"), VmStatus::Halted); let rebound = vm.locals()[0].clone(); assert_eq!( @@ -618,7 +1122,7 @@ fn store_reset_and_replacement_invalidate_callback_registries() { .expect("first callback should bind"); let prepared = callback.prepare((1,)).expect("callback should prepare"); - store.reset_for_reuse(); + let _ = store.reset_for_reuse(); assert!(!callback.is_subscribed()); assert!(matches!( store.enqueue_callback(prepared), @@ -1993,3 +2497,899 @@ fn call_ret_fusion_pattern_requires_immediate_ret() { vm_no_next.instance.ip = 4; assert!(!vm_no_next.can_fuse_call_ret_pattern()); } + +#[test] +fn async_host_future_is_submitted_to_the_host_bridge() { + use std::sync::{Arc, Mutex}; + + struct RecordingBridge { + submitted: Arc>>, + future: Arc>>, + } + + impl HostAsyncBridge for RecordingBridge { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.submitted.lock().expect("submitted lock").push(op_id); + *self.future.lock().expect("future lock") = Some(future); + Ok(()) + } + + fn poll_op( + &mut self, + _op_id: HostOpId, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + } + + let submitted = Arc::new(Mutex::new(Vec::new())); + let future = Arc::new(Mutex::new(None)); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(RecordingBridge { + submitted: Arc::clone(&submitted), + future: Arc::clone(&future), + })) + .expect("recording bridge should install"); + + let outcome = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::one(Value::Int(42)))) + })) + .expect("host bridge should accept future"); + let CallOutcome::Pending(op_id) = outcome else { + panic!("async host submission should suspend"); + }; + + assert_eq!(*submitted.lock().expect("submitted lock"), vec![op_id]); + assert!(future.lock().expect("future lock").is_some()); + assert!( + vm.host.submitted_host_ops.contains(&op_id), + "submitted bridge op should be tracked in the host runtime" + ); +} + +#[test] +fn async_host_future_completion_error_cleans_up_bridge_operation_once() { + struct FailingCompletionBridge { + cleanup_calls: Arc>>, + } + + impl HostAsyncBridge for FailingCompletionBridge { + fn submit_op(&mut self, _op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + Ok(()) + } + + fn poll_op( + &mut self, + _op_id: HostOpId, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } + + fn poll_submitted_op( + &mut self, + _op_id: HostOpId, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(HostFutureOutput::complete(|_vm| { + Err(VmError::HostError("completion failed".to_string())) + }))) + } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.cleanup_calls.lock().expect("cleanup lock").push(op_id); + } + } + + struct NoopWake; + impl std::task::Wake for NoopWake { + fn wake(self: Arc) {} + } + + let cleanup_calls = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(FailingCompletionBridge { + cleanup_calls: Arc::clone(&cleanup_calls), + })) + .expect("failing completion bridge should install"); + let CallOutcome::Pending(op_id) = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::none())) + })) + .expect("host bridge should accept future") + else { + panic!("async host submission should suspend"); + }; + vm.set_waiting_host_op_with_return(op_id, WaitingHostOpSource::HostBridge, None, None) + .expect("vm should wait on submitted operation"); + + let waker = Waker::from(Arc::new(NoopWake)); + let mut cx = Context::from_waker(&waker); + let result = vm.poll_waiting_host_op(&mut cx); + assert!( + matches!(result, Poll::Ready(Err(VmError::HostError(message))) if message == "completion failed") + ); + assert_eq!(vm.waiting_host_op_id(), None); + assert!(!vm.host.submitted_host_ops.contains(&op_id)); + assert_eq!(*cleanup_calls.lock().expect("cleanup lock"), vec![op_id]); + + assert!(matches!( + vm.poll_waiting_host_op(&mut cx), + Poll::Ready(Ok(())) + )); + assert_eq!(*cleanup_calls.lock().expect("cleanup lock"), vec![op_id]); +} + +#[derive(Default)] +struct CleanupRecordingBridge { + cancellations: Arc>>, + submissions: Arc>>, +} + +impl HostAsyncBridge for CleanupRecordingBridge { + fn submit_op(&mut self, op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + self.submissions + .lock() + .expect("submission lock") + .push(op_id); + Ok(()) + } + + fn poll_op(&mut self, _op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + + fn cancel_op_with_reason(&mut self, op_id: HostOpId, reason: OperationCancelReason) { + self.cancellations + .lock() + .expect("cancellation lock") + .push((op_id, reason)); + } + + fn request_cancel_op( + &mut self, + op_id: HostOpId, + reason: OperationCancelReason, + ) -> VmResult<()> { + self.cancel_op_with_reason(op_id, reason); + Ok(()) + } + + fn poll_cancel_op(&mut self, _op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn cleanup_op(&mut self, op_id: HostOpId, terminal: HostAsyncOpTerminal) -> VmResult<()> { + if terminal == HostAsyncOpTerminal::Completed { + self.cancel_op_with_reason(op_id, OperationCancelReason::Requested); + } + Ok(()) + } +} + +#[test] +fn manually_completing_submitted_bridge_op_retires_entry_once() { + let cancellations = Arc::new(Mutex::new(Vec::new())); + let submissions = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(CleanupRecordingBridge { + cancellations: Arc::clone(&cancellations), + submissions: Arc::clone(&submissions), + })) + .expect("cleanup bridge should install"); + + let CallOutcome::Pending(op_id) = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::none())) + })) + .expect("submission") + else { + panic!("submission should suspend"); + }; + vm.set_waiting_host_op_with_return(op_id, WaitingHostOpSource::HostBridge, None, None) + .expect("waiting state"); + + vm.complete_host_op(op_id, CallReturn::one(Value::Int(7))) + .expect("manual completion"); + + assert_eq!(vm.waiting_host_op_id(), None); + assert!(vm.host.submitted_host_ops.is_empty()); + assert_eq!(vm.stack(), &[Value::Int(7)]); + assert_eq!( + *cancellations.lock().expect("cancellation lock"), + vec![(op_id, OperationCancelReason::Requested)] + ); + assert_eq!(*submissions.lock().expect("submission lock"), vec![op_id]); + + let error = vm + .complete_host_op(op_id, CallReturn::none()) + .expect_err("a second completion has no waiting operation"); + assert!(error.to_string().contains("not waiting on any op")); + assert_eq!( + cancellations.lock().expect("cancellation lock").as_slice(), + &[(op_id, OperationCancelReason::Requested)] + ); +} + +#[test] +fn legacy_manual_pending_without_bridge_is_untracked_and_resets_cleanly() { + struct LegacyManualPending; + + impl HostFunction for LegacyManualPending { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Pending(404)) + } + } + + let mut bytecode = BytecodeBuilder::new(); + bytecode.call(0, 0); + bytecode.ret(); + let mut vm = Vm::new(Program::new(Vec::new(), bytecode.finish())); + vm.register_function(Box::new(LegacyManualPending)); + + assert_eq!( + vm.run().expect("legacy host op should suspend"), + VmStatus::Waiting(404) + ); + let waiting = vm + .instance + .waiting_host_op + .as_ref() + .expect("legacy pending operation should be recorded"); + assert_eq!(waiting.source, WaitingHostOpSource::Manual); + assert!( + !vm.host.is_bridge_operation_tracked(404), + "manual completion must not enter bridge tracking" + ); + + vm.reset_for_reuse() + .expect("reset must clear a legacy manual pending operation"); + assert_eq!(vm.waiting_host_op_id(), None); + assert!(!vm.host.is_bridge_operation_tracked(404)); + assert!(vm.is_reusable()); +} + +struct ScopedPendingDriver { + cancellations: Arc>>, +} + +impl HostOperation for ScopedPendingDriver { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + + fn cancel(&mut self, reason: OperationCancelReason) -> OperationResult<()> { + self.cancellations + .lock() + .expect("scoped cancellation lock") + .push(reason); + Ok(()) + } + + fn is_quiescent(&self) -> bool { + true + } +} + +#[test] +fn manually_completing_scoped_op_retires_operation_and_completion() { + let cancellations = Arc::new(Mutex::new(Vec::new())); + let completion_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let op_id = vm + .execution_scope() + .start_operation(OperationSpec::new(ScopedPendingDriver { + cancellations: Arc::clone(&cancellations), + })) + .expect("scoped operation"); + let completion_calls_for_hook = Arc::clone(&completion_calls); + vm.register_scoped_operation_completion(op_id, move |_vm, _outcome| { + completion_calls_for_hook.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(CallReturn::none()) + }) + .expect("completion hook"); + vm.set_waiting_host_op_with_return( + op_id.raw(), + WaitingHostOpSource::ScopedOperation, + None, + None, + ) + .expect("waiting state"); + + vm.complete_host_op(op_id.raw(), CallReturn::none()) + .expect("manual completion"); + + assert_eq!(vm.waiting_host_op_id(), None); + assert_eq!(vm.host.execution_scope.operations().len(), 0); + assert!(vm.host.scoped_operation_completions.is_empty()); + assert_eq!( + cancellations + .lock() + .expect("scoped cancellation lock") + .as_slice(), + &[OperationCancelReason::Requested] + ); + assert_eq!( + completion_calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "manual completion must remove the adapter hook instead of invoking it" + ); +} + +#[test] +fn reset_cancels_all_submitted_bridge_ops_and_retires_scoped_state() { + let cancellations = Arc::new(Mutex::new(Vec::new())); + let submissions = Arc::new(Mutex::new(Vec::new())); + let scoped_cancellations = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(CleanupRecordingBridge { + cancellations: Arc::clone(&cancellations), + submissions: Arc::clone(&submissions), + })) + .expect("cleanup bridge should install"); + let CallOutcome::Pending(first) = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::none())) + })) + .expect("first submission") + else { + panic!("first submission should suspend"); + }; + let CallOutcome::Pending(second) = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::none())) + })) + .expect("second submission") + else { + panic!("second submission should suspend"); + }; + let scoped_id = vm + .execution_scope() + .start_operation(OperationSpec::new(ScopedPendingDriver { + cancellations: Arc::clone(&scoped_cancellations), + })) + .expect("scoped operation"); + vm.register_scoped_operation_completion(scoped_id, |_vm, _outcome| Ok(CallReturn::none())) + .expect("scoped completion"); + + let _ = vm.reset_for_reuse(); + + let mut bridge_cancellations = cancellations.lock().expect("cancellation lock").clone(); + bridge_cancellations.sort_unstable(); + assert_eq!( + bridge_cancellations, + vec![ + (first, OperationCancelReason::VmReset), + (second, OperationCancelReason::VmReset), + ] + ); + assert_eq!( + *submissions.lock().expect("submission lock"), + vec![first, second] + ); + assert!(vm.host.submitted_host_ops.is_empty()); + assert!(vm.host.scoped_operation_completions.is_empty()); + assert_eq!(vm.host.execution_scope.operations().len(), 0); + assert_eq!( + scoped_cancellations + .lock() + .expect("scoped cancellation lock") + .as_slice(), + &[OperationCancelReason::VmReset] + ); +} + +#[test] +fn dropping_vm_cancels_all_submitted_bridge_ops_once() { + let cancellations = Arc::new(Mutex::new(Vec::new())); + let submissions = Arc::new(Mutex::new(Vec::new())); + { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(CleanupRecordingBridge { + cancellations: Arc::clone(&cancellations), + submissions: Arc::clone(&submissions), + })) + .expect("cleanup bridge should install"); + let CallOutcome::Pending(first) = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::none())) + })) + .expect("first submission") + else { + panic!("first submission should suspend"); + }; + let CallOutcome::Pending(second) = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::none())) + })) + .expect("second submission") + else { + panic!("second submission should suspend"); + }; + assert_eq!( + *submissions.lock().expect("submission lock"), + vec![first, second] + ); + } + + let mut recorded = cancellations.lock().expect("cancellation lock").clone(); + recorded.sort_unstable(); + let submitted = submissions.lock().expect("submission lock").clone(); + assert_eq!( + recorded, + submitted + .into_iter() + .map(|id| (id, OperationCancelReason::VmDrop)) + .collect::>() + ); +} + +struct QuiescentAdmissionResource; + +impl crate::vm::resource::HostResource for QuiescentAdmissionResource {} + +struct PendingResetResource { + ready: Arc, +} + +impl crate::vm::resource::HostResource for PendingResetResource { + fn begin_close( + &mut self, + _reason: ResourceCloseReason, + ) -> crate::vm::resource::error::ResourceResult { + Ok(crate::vm::resource::CloseProgress::Pending) + } + + fn poll_close( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + if self.ready.load(Ordering::SeqCst) { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } +} + +#[test] +fn reset_retains_one_replacement_scope_until_old_scope_quiesces() { + static ARENA_SOURCE: AtomicU64 = AtomicU64::new(1); + static REGISTRY_SOURCE: AtomicU64 = AtomicU64::new(1); + ARENA_SOURCE.store(1, Ordering::SeqCst); + REGISTRY_SOURCE.store(1, Ordering::SeqCst); + + let ready = Arc::new(AtomicBool::new(false)); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.execution_scope() + .push_resource(PendingResetResource { + ready: Arc::clone(&ready), + }) + .expect("pending resource should enter the active scope"); + assert!(!vm.is_reusable(), "旧 scope 持有资源时 VM 不应进入复用池"); + + let _arena_source = + crate::vm::resource::table::test_seam::ScopedArenaSource::install(&ARENA_SOURCE); + let _registry_source = + crate::vm::operation::id::test_seam::ScopedRegistryTagSource::install(®ISTRY_SOURCE); + + vm.reset_for_reuse() + .expect("reset should retain the replacement while close is pending"); + assert!( + vm.scope_reset_pending(), + "旧 scope 未 quiescent 时 reset 应保持 pending" + ); + assert_eq!( + ARENA_SOURCE.load(Ordering::SeqCst), + 2, + "每次 reset 只应分配一个 replacement arena identity" + ); + assert_eq!( + REGISTRY_SOURCE.load(Ordering::SeqCst), + 2, + "每次 reset 只应分配一个 replacement registry identity" + ); + + // If polling tried to allocate a second scope, both sources would reject + // the allocation. A retained replacement must let the old close finish. + ARENA_SOURCE.store( + crate::vm::resource::handle::MAX_HANDLE_ARENA_ID + 1, + Ordering::SeqCst, + ); + REGISTRY_SOURCE.store( + crate::vm::operation::id::MAX_REGISTRY_TAG + 1, + Ordering::SeqCst, + ); + ready.store(true, Ordering::SeqCst); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(!vm.scope_reset_pending()); + assert_eq!( + ARENA_SOURCE.load(Ordering::SeqCst), + crate::vm::resource::handle::MAX_HANDLE_ARENA_ID + 1, + "完成 close 后不得再次消耗 arena identity" + ); + assert_eq!( + REGISTRY_SOURCE.load(Ordering::SeqCst), + crate::vm::operation::id::MAX_REGISTRY_TAG + 1, + "完成 close 后不得再次消耗 registry identity" + ); + + ARENA_SOURCE.store(2, Ordering::SeqCst); + REGISTRY_SOURCE.store(2, Ordering::SeqCst); + vm.reset_for_reuse() + .expect("第二次 reset 应只创建一个 replacement"); + assert_eq!(ARENA_SOURCE.load(Ordering::SeqCst), 3); + assert_eq!(REGISTRY_SOURCE.load(Ordering::SeqCst), 3); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + assert_eq!(ARENA_SOURCE.load(Ordering::SeqCst), 3); + assert_eq!(REGISTRY_SOURCE.load(Ordering::SeqCst), 3); +} + +#[test] +fn replacement_scope_allocation_failure_is_terminal_and_not_reusable() { + static ARENA_SOURCE: AtomicU64 = AtomicU64::new(1); + ARENA_SOURCE.store( + crate::vm::resource::handle::MAX_HANDLE_ARENA_ID + 1, + Ordering::SeqCst, + ); + + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let _arena_source = + crate::vm::resource::table::test_seam::ScopedArenaSource::install(&ARENA_SOURCE); + + let error = vm + .reset_for_reuse() + .expect_err("replacement scope allocation failure must be reported"); + assert!(matches!( + error, + VmError::ExecutionScope(crate::vm::execution_scope::ExecutionScopeError::ArenaExhausted(_)) + )); + assert!( + !vm.scope_reset_pending(), + "terminal reset error 不应伪装为 pending" + ); + assert!( + !vm.is_reusable(), + "terminal reset error 下 VM 不得进入复用池" + ); + + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.poll_reset_for_reuse(&mut cx), + Poll::Ready(Err(VmError::ExecutionScope( + crate::vm::execution_scope::ExecutionScopeError::ArenaExhausted(_) + ))) + )); + assert!( + !vm.is_reusable(), + "重复 poll 后 terminal reset error 仍应保持" + ); +} + +#[test] +fn store_does_not_publish_callback_registry_after_reset_allocation_failure() { + static ARENA_SOURCE: AtomicU64 = AtomicU64::new(1); + ARENA_SOURCE.store( + crate::vm::resource::handle::MAX_HANDLE_ARENA_ID + 1, + Ordering::SeqCst, + ); + + let compiled = crate::compile_source("pub fn value() -> int { 1 }") + .expect("callback failure program should compile"); + let mut store = crate::vm::Store::new(Vm::new(compiled.program), ()); + let callback = store + .script_callback_by_name::<(), i64>("value") + .expect("callback should exist before reset failure"); + let _arena_source = + crate::vm::resource::table::test_seam::ScopedArenaSource::install(&ARENA_SOURCE); + + assert!(store.reset_for_reuse().is_err(), "reset 失败应返回错误"); + assert!(!callback.is_subscribed(), "reset 失败应使旧 callback 失效"); + assert!(!store.is_reusable(), "reset 失败后 store 不得进入复用池"); + assert!(matches!( + store.script_callback_by_name::<(), i64>("value"), + Err(VmError::ExecutionScope( + crate::vm::execution_scope::ExecutionScopeError::ArenaExhausted(_) + )) + )); +} + +#[test] +fn shutdown_vm_is_not_reusable() { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + assert!(vm.is_reusable(), "fresh VM 应可复用"); + vm.shutdown(); + assert!(!vm.is_reusable(), "shutdown VM 不得进入复用池"); +} + +#[test] +fn pending_invocation_makes_vm_non_reusable() { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.instance.invocation = Some(crate::vm::invocation::InvocationState { + phase: crate::vm::invocation::InvocationPhase::Running, + emit_yield_pending: false, + pending_error: None, + cancel_reason: None, + stack_base: 0, + frame_count: vm.instance.execution_frames.len(), + }); + + assert!( + !vm.is_reusable(), + "pending invocation 存在时 VM 不应进入复用池" + ); +} + +#[test] +fn quiescent_scope_rejects_resource_admission() { + let mut scope = crate::vm::execution_scope::ExecutionScope::new().expect("scope"); + scope + .begin_close(ResourceCloseReason::Requested) + .expect("close"); + assert!(matches!( + scope.poll_close(&mut Context::from_waker(Waker::noop())), + Poll::Ready(Ok(crate::vm::execution_scope::ScopeCloseOutcome::Success)) + )); + let error = scope + .push_resource(QuiescentAdmissionResource) + .expect_err("quiescent scope rejects resource insertion"); + assert_eq!( + error, + crate::vm::execution_scope::ExecutionScopeError::ScopeClosing + ); +} + +#[test] +fn capability_profile_allow_all_and_deny_all_differ() { + let allow_all = crate::vm::CapabilityProfile::allow_all(); + let deny_all = crate::vm::CapabilityProfile::deny_all(); + assert!(allow_all.allows_builtin(crate::BuiltinFunction::Len)); + assert!(allow_all.allows_host_import("anything::at::all")); + assert!(!deny_all.allows_builtin(crate::BuiltinFunction::Len)); + assert!(!deny_all.allows_host_import("anything::at::all")); + assert_ne!(allow_all.fingerprint(), deny_all.fingerprint()); +} + +struct DelayedCancellationBridge { + acknowledgement: Arc, + cancellations: Arc>>, + cleanups: Arc>>, +} + +impl HostAsyncBridge for DelayedCancellationBridge { + fn submit_op(&mut self, _op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + Ok(()) + } + + fn poll_op(&mut self, _op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + + fn request_cancel_op( + &mut self, + op_id: HostOpId, + reason: OperationCancelReason, + ) -> VmResult<()> { + self.cancellations + .lock() + .expect("cancellation lock") + .push((op_id, reason)); + Ok(()) + } + + fn poll_cancel_op(&mut self, _op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + if self.acknowledgement.load(Ordering::SeqCst) { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + fn cleanup_op(&mut self, op_id: HostOpId, terminal: HostAsyncOpTerminal) -> VmResult<()> { + self.cleanups + .lock() + .expect("cleanup lock") + .push((op_id, terminal)); + Ok(()) + } +} + +struct NoAcknowledgementBridge; + +impl HostAsyncBridge for NoAcknowledgementBridge { + fn submit_op(&mut self, _op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + Ok(()) + } + + fn poll_op(&mut self, _op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } +} + +fn submitted_host_future(vm: &mut Vm) -> HostOpId { + let CallOutcome::Pending(op_id) = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::none())) + })) + .expect("host bridge should accept future") + else { + panic!("host submission should suspend"); + }; + op_id +} + +#[test] +fn bridge_cancellation_acknowledgement_gates_reset_and_pool_reuse() { + let acknowledgement = Arc::new(AtomicBool::new(false)); + let cancellations = Arc::new(Mutex::new(Vec::new())); + let cleanups = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(DelayedCancellationBridge { + acknowledgement: Arc::clone(&acknowledgement), + cancellations: Arc::clone(&cancellations), + cleanups: Arc::clone(&cleanups), + })) + .expect("bridge installation should succeed"); + let op_id = submitted_host_future(&mut vm); + let mut store = Store::from_vm(vm); + + store + .reset_for_reuse() + .expect("reset may remain pending while cancellation is unacknowledged"); + assert!(store.vm().scope_reset_pending()); + assert!(!store.is_reusable()); + assert!(store.vm().host.submitted_host_ops.contains(&op_id)); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!(store.poll_reset_for_reuse(&mut cx), Poll::Pending)); + assert!(!store.is_reusable()); + assert_eq!( + *cancellations.lock().expect("cancellation lock"), + vec![(op_id, OperationCancelReason::VmReset)] + ); + assert!(cleanups.lock().expect("cleanup lock").is_empty()); + + acknowledgement.store(true, Ordering::SeqCst); + assert!(matches!( + store.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(store.is_reusable()); + assert!(store.vm().host.submitted_host_ops.is_empty()); + assert_eq!( + *cleanups.lock().expect("cleanup lock"), + vec![(op_id, HostAsyncOpTerminal::Cancelled)] + ); +} + +#[test] +fn bridge_without_cancellation_acknowledgement_fails_closed() { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(NoAcknowledgementBridge)) + .expect("bridge installation should succeed"); + let op_id = submitted_host_future(&mut vm); + + let error = vm + .reset_for_reuse() + .expect_err("a bridge without cancellation acknowledgement must not report reset success"); + assert!( + matches!(error, VmError::HostError(ref message) if message.contains("cancellation acknowledgement")), + "unexpected reset error: {error:?}" + ); + assert!(vm.host.submitted_host_ops.contains(&op_id)); + assert!(!vm.is_reusable()); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.poll_reset_for_reuse(&mut cx), + Poll::Ready(Err(VmError::HostError(message))) if message.contains("cancellation acknowledgement") + )); +} + +#[test] +fn repeated_bridge_cancellation_preserves_first_reason_and_cleans_once() { + let acknowledgement = Arc::new(AtomicBool::new(false)); + let cancellations = Arc::new(Mutex::new(Vec::new())); + let cleanups = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(DelayedCancellationBridge { + acknowledgement: Arc::clone(&acknowledgement), + cancellations: Arc::clone(&cancellations), + cleanups: Arc::clone(&cleanups), + })) + .expect("bridge installation should succeed"); + let op_id = submitted_host_future(&mut vm); + vm.set_waiting_host_op_with_return(op_id, WaitingHostOpSource::HostBridge, None, None) + .expect("submitted operation should become the waiting operation"); + + vm.cancel_waiting_host_op_with_reason(OperationCancelReason::Deadline) + .expect("first cancellation request should be accepted"); + vm.cancel_waiting_host_op_with_reason(OperationCancelReason::VmReset) + .expect("repeated cancellation should be idempotent"); + assert_eq!( + *cancellations.lock().expect("cancellation lock"), + vec![(op_id, OperationCancelReason::Deadline)] + ); + + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!(vm.poll_waiting_host_op(&mut cx), Poll::Pending)); + acknowledgement.store(true, Ordering::SeqCst); + assert!(matches!( + vm.poll_waiting_host_op(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(vm.host.submitted_host_ops.is_empty()); + assert_eq!( + *cleanups.lock().expect("cleanup lock"), + vec![(op_id, HostAsyncOpTerminal::Cancelled)] + ); + assert!(matches!( + vm.poll_waiting_host_op(&mut cx), + Poll::Ready(Ok(())) + )); + assert_eq!( + *cleanups.lock().expect("cleanup lock"), + vec![(op_id, HostAsyncOpTerminal::Cancelled)] + ); +} + +#[test] +fn active_bridge_operation_rejects_bridge_replacement_until_quiescent() { + let acknowledgement = Arc::new(AtomicBool::new(false)); + let cancellations = Arc::new(Mutex::new(Vec::new())); + let cleanups = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(DelayedCancellationBridge { + acknowledgement: Arc::clone(&acknowledgement), + cancellations, + cleanups, + })) + .expect("bridge installation should succeed"); + let op_id = submitted_host_future(&mut vm); + + let error = vm + .set_async_bridge(Box::new(NoAcknowledgementBridge)) + .expect_err("active old bridge operations must reject replacement"); + assert!( + matches!(error, VmError::HostError(ref message) if message.contains("active host operation")), + "unexpected replacement error: {error:?}" + ); + assert!(vm.host.submitted_host_ops.contains(&op_id)); + + vm.reset_for_reuse() + .expect("reset should wait for the old bridge acknowledgement"); + acknowledgement.store(true, Ordering::SeqCst); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + vm.set_async_bridge(Box::new(NoAcknowledgementBridge)) + .expect("replacement is allowed after old bridge quiescence"); +} + +#[test] +fn dropping_vm_requests_bridge_cancellation_without_claiming_reuse() { + let acknowledgement = Arc::new(AtomicBool::new(false)); + let cancellations = Arc::new(Mutex::new(Vec::new())); + let cleanups = Arc::new(Mutex::new(Vec::new())); + { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(DelayedCancellationBridge { + acknowledgement, + cancellations: Arc::clone(&cancellations), + cleanups, + })) + .expect("bridge installation should succeed"); + let _ = submitted_host_future(&mut vm); + } + assert_eq!(cancellations.lock().expect("cancellation lock").len(), 1); +} diff --git a/src/vmbc.rs b/src/vmbc.rs index 1ac65b68..efca75ab 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -8,10 +8,15 @@ use crate::bytecode::{ }; use crate::compiler::ir::TypeSchema; use crate::debug_info::{ArgInfo, DebugFunction, DebugInfo, LineInfo, LocalInfo}; +use crate::host_api::{ + HostApiFingerprint, HostImportParam, HostImportSchema, HostParamPassing, HostTypeSchema, + ResourceTypeKey, +}; use crate::vm::{HostImport, OpCode, Program, Value}; const MAGIC: [u8; 4] = *b"VMBC"; const VERSION_V11: u16 = 11; +const VERSION_V12: u16 = 12; const FLAGS: u16 = 0; #[derive(Debug, Clone, PartialEq, Eq)] @@ -26,6 +31,10 @@ pub enum WireError { InvalidDebugFlag(u8), InvalidValueType(u8), InvalidCaptureBindingMode(u8), + InvalidHostSchemaTag(u8), + InvalidHostParamPassing(u8), + InvalidHostResourceKey, + HostSchemaImportMismatch, InvalidUtf8, StringTooLong(usize), CodeTooLong(usize), @@ -51,6 +60,18 @@ impl std::fmt::Display for WireError { WireError::InvalidCaptureBindingMode(value) => { write!(f, "invalid capture binding mode: {value}") } + WireError::InvalidHostSchemaTag(value) => { + write!(f, "invalid host schema tag: {value}") + } + WireError::InvalidHostParamPassing(value) => { + write!(f, "invalid host parameter passing mode: {value}") + } + WireError::InvalidHostResourceKey => { + write!(f, "invalid host resource type key") + } + WireError::HostSchemaImportMismatch => { + write!(f, "host import schema does not match its import") + } WireError::InvalidUtf8 => write!(f, "invalid utf-8 string"), WireError::StringTooLong(len) => write!(f, "string too long: {len}"), WireError::CodeTooLong(len) => write!(f, "code too long: {len}"), @@ -241,7 +262,7 @@ fn read_constant(cursor: &mut Cursor<'_>, depth: usize) -> Result Result, WireError> { let mut out = Vec::new(); out.extend_from_slice(&MAGIC); - out.extend_from_slice(&VERSION_V11.to_le_bytes()); + out.extend_from_slice(&VERSION_V12.to_le_bytes()); out.extend_from_slice(&FLAGS.to_le_bytes()); write_u32_count("constants", program.constants.len(), &mut out)?; @@ -253,10 +274,19 @@ pub fn encode_program(program: &Program) -> Result, WireError> { out.extend_from_slice(&program.code); write_u32_count("imports", program.imports.len(), &mut out)?; - for import in &program.imports { + for (index, import) in program.imports.iter().enumerate() { write_string("import name", &import.name, &mut out)?; out.push(import.arity); out.push(import.return_type as u8); + let schema = if program.host_import_schemas.is_empty() { + None + } else { + if program.host_import_schemas.len() != program.imports.len() { + return Err(WireError::HostSchemaImportMismatch); + } + program.host_import_schemas[index].as_ref() + }; + write_optional_host_import_schema(schema, &mut out)?; } write_type_map(&mut out, program.type_map.as_ref())?; @@ -275,9 +305,11 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V11 { - return Err(WireError::UnsupportedVersion(version)); - } + let has_host_import_schemas = match version { + VERSION_V11 => false, + VERSION_V12 => true, + _ => return Err(WireError::UnsupportedVersion(version)), + }; let flags = cursor.read_u16()?; if flags != FLAGS { @@ -294,12 +326,27 @@ pub fn decode_program(bytes: &[u8]) -> Result { let code = cursor.read_exact(code_len)?.to_vec(); let import_count = cursor.read_u32()? as usize; let mut imports = Vec::with_capacity(import_count); + let mut host_import_schemas = if has_host_import_schemas { + Vec::with_capacity(import_count) + } else { + Vec::new() + }; for _ in 0..import_count { - imports.push(HostImport { + let import = HostImport { name: cursor.read_string()?, 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); + } + imports.push(import); } let type_map = read_type_map(&mut cursor)?; let debug = read_debug_info(&mut cursor)?; @@ -316,6 +363,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let mut program = Program::with_imports_and_debug(constants, code, imports, debug); + program.host_import_schemas = host_import_schemas; program.type_map = type_map; program.script_functions = script_functions; program.callable_prototypes = callable_prototypes; @@ -1290,6 +1338,177 @@ fn read_optional_schema(cursor: &mut Cursor<'_>) -> Result, W } } +const MAX_HOST_SCHEMA_DEPTH: usize = 64; + +fn write_optional_host_import_schema( + schema: Option<&HostImportSchema>, + out: &mut Vec, +) -> Result<(), WireError> { + match schema { + Some(schema) => { + out.push(1); + write_host_import_schema(schema, out)?; + } + None => out.push(0), + } + Ok(()) +} + +fn read_optional_host_import_schema( + cursor: &mut Cursor<'_>, +) -> Result, WireError> { + match cursor.read_u8()? { + 0 => Ok(None), + 1 => Ok(Some(read_host_import_schema(cursor)?)), + other => Err(WireError::InvalidBool(other)), + } +} + +fn write_host_import_schema(schema: &HostImportSchema, out: &mut Vec) -> Result<(), WireError> { + write_string("host import schema name", &schema.name, out)?; + write_u32_count("host import schema parameters", schema.params.len(), out)?; + for param in &schema.params { + write_string("host import parameter name", ¶m.name, out)?; + write_host_type_schema(¶m.schema, out, 0)?; + out.push(match param.passing { + HostParamPassing::Value => 0, + HostParamPassing::Borrow => 1, + HostParamPassing::BorrowMut => 2, + HostParamPassing::TakeOwned => 3, + }); + } + write_host_type_schema(&schema.return_type, out, 0)?; + out.extend_from_slice(&schema.fingerprint.as_u64().to_le_bytes()); + Ok(()) +} + +fn read_host_import_schema(cursor: &mut Cursor<'_>) -> Result { + let name = cursor.read_string()?; + let param_count = cursor.read_u32()? as usize; + let mut params = Vec::with_capacity(param_count); + for _ in 0..param_count { + let param_name = cursor.read_string()?; + let schema = read_host_type_schema(cursor, 0)?; + let passing = match cursor.read_u8()? { + 0 => HostParamPassing::Value, + 1 => HostParamPassing::Borrow, + 2 => HostParamPassing::BorrowMut, + 3 => HostParamPassing::TakeOwned, + other => return Err(WireError::InvalidHostParamPassing(other)), + }; + params.push(HostImportParam { + name: param_name, + schema, + passing, + }); + } + let return_type = read_host_type_schema(cursor, 0)?; + let fingerprint = HostApiFingerprint::from_wire(cursor.read_u64()?); + Ok(HostImportSchema { + name, + params, + return_type, + fingerprint, + }) +} + +fn write_host_type_schema( + schema: &HostTypeSchema, + out: &mut Vec, + depth: usize, +) -> Result<(), WireError> { + if depth >= MAX_HOST_SCHEMA_DEPTH { + return Err(WireError::LengthTooLarge( + "host schema nesting depth", + depth, + )); + } + match schema { + HostTypeSchema::Unknown => out.push(0), + HostTypeSchema::Null => out.push(1), + HostTypeSchema::Int => out.push(2), + HostTypeSchema::Float => out.push(3), + HostTypeSchema::Number => out.push(4), + HostTypeSchema::Bool => out.push(5), + HostTypeSchema::String => out.push(6), + HostTypeSchema::Bytes => out.push(7), + HostTypeSchema::Array(inner) => { + out.push(8); + write_host_type_schema(inner, out, depth + 1)?; + } + HostTypeSchema::Map(inner) => { + out.push(9); + write_host_type_schema(inner, out, depth + 1)?; + } + HostTypeSchema::Optional(inner) => { + out.push(10); + write_host_type_schema(inner, out, depth + 1)?; + } + HostTypeSchema::Callable { params, result } => { + out.push(11); + write_u32_count("host callable parameters", params.len(), out)?; + for param in params { + write_host_type_schema(param, out, depth + 1)?; + } + write_host_type_schema(result, out, depth + 1)?; + } + HostTypeSchema::Resource(key) => { + out.push(12); + write_string("host resource type key", key.as_str(), out)?; + } + } + Ok(()) +} + +fn read_host_type_schema( + cursor: &mut Cursor<'_>, + depth: usize, +) -> Result { + if depth >= MAX_HOST_SCHEMA_DEPTH { + return Err(WireError::LengthTooLarge( + "host schema nesting depth", + depth, + )); + } + match cursor.read_u8()? { + 0 => Ok(HostTypeSchema::Unknown), + 1 => Ok(HostTypeSchema::Null), + 2 => Ok(HostTypeSchema::Int), + 3 => Ok(HostTypeSchema::Float), + 4 => Ok(HostTypeSchema::Number), + 5 => Ok(HostTypeSchema::Bool), + 6 => Ok(HostTypeSchema::String), + 7 => Ok(HostTypeSchema::Bytes), + 8 => Ok(HostTypeSchema::Array(Box::new(read_host_type_schema( + cursor, + depth + 1, + )?))), + 9 => Ok(HostTypeSchema::Map(Box::new(read_host_type_schema( + cursor, + depth + 1, + )?))), + 10 => Ok(HostTypeSchema::Optional(Box::new(read_host_type_schema( + cursor, + depth + 1, + )?))), + 11 => { + let count = cursor.read_u32()? as usize; + let mut params = Vec::with_capacity(count); + for _ in 0..count { + params.push(read_host_type_schema(cursor, depth + 1)?); + } + let result = Box::new(read_host_type_schema(cursor, depth + 1)?); + Ok(HostTypeSchema::Callable { params, result }) + } + 12 => { + let key = ResourceTypeKey::new(cursor.read_string()?) + .map_err(|_| WireError::InvalidHostResourceKey)?; + Ok(HostTypeSchema::Resource(key)) + } + other => Err(WireError::InvalidHostSchemaTag(other)), + } +} + fn write_schema(schema: &TypeSchema, out: &mut Vec) -> Result<(), WireError> { match schema { TypeSchema::Unknown => out.push(0), @@ -1469,6 +1688,11 @@ impl<'a> Cursor<'a> { Ok(u32::from_le_bytes(bytes)) } + fn read_u64(&mut self) -> Result { + let bytes = self.read_exact_array::<8>()?; + Ok(u64::from_le_bytes(bytes)) + } + fn read_i64(&mut self) -> Result { let bytes = self.read_exact_array::<8>()?; Ok(i64::from_le_bytes(bytes)) diff --git a/tests/build_source_selection_tests.rs b/tests/build_source_selection_tests.rs new file mode 100644 index 00000000..8d5e3714 --- /dev/null +++ b/tests/build_source_selection_tests.rs @@ -0,0 +1,4 @@ +#![allow(dead_code)] + +#[path = "../build.rs"] +mod build_script; diff --git a/tests/builtins/io_async_tests.rs b/tests/builtins/io_async_tests.rs new file mode 100644 index 00000000..4f25b473 --- /dev/null +++ b/tests/builtins/io_async_tests.rs @@ -0,0 +1,307 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use vm::{Value, Vm, VmError, VmStatus, compile_source}; + +fn run_source(source: &str) -> Result, VmError> { + let compiled = + compile_source(&format!("use io;\n{source}")).expect("async io source should compile"); + let mut vm = Vm::new(compiled.program); + super::async_test_bridge::install(&mut vm); + + let mut status = vm.run()?; + loop { + match status { + VmStatus::Halted => return Ok(vm.stack().to_vec()), + VmStatus::Yielded => status = vm.resume()?, + VmStatus::Waiting(_) => { + vm.wait_for_host_op_blocking()?; + status = vm.resume()?; + } + } + } +} + +#[test] +fn async_io_round_trips_file_operations_through_host_driver() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("pd-vm-async-io-{}-{nonce}", std::process::id())); + + let stack = run_source(&format!( + r#" + let handle = io::open("{}", "w"); + io::write(handle, "host-driven"); + io::flush(handle); + io::close(handle); + io::exists("{}"); + "#, + path.display(), + path.display(), + )) + .expect("async io program should complete"); + + assert_eq!(stack.last(), Some(&Value::Bool(true))); + assert_eq!( + std::fs::read_to_string(&path).expect("written file should exist"), + "host-driven" + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn async_io_read_line_preserves_buffered_data_between_calls() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "pd-vm-async-read-line-{}-{nonce}", + std::process::id() + )); + std::fs::write(&path, "first\nsecond\n").expect("fixture should be written"); + + let stack = run_source(&format!( + r#" + let handle = io::open("{}", "r"); + io::read_line(handle); + let second = io::read_line(handle); + io::close(handle); + second; + "#, + path.display(), + )) + .expect("async read_line program should complete"); + + assert_eq!(stack.last(), Some(&Value::string("second\n"))); + let _ = std::fs::remove_file(path); +} + +#[cfg(unix)] +#[test] +fn async_io_popen_reads_through_tokio_process_pipe() { + let stack = run_source( + r#" + let handle = io::popen("printf async-process", "r"); + let output = io::read_all(handle); + io::close(handle); + output; + "#, + ) + .expect("async popen program should complete"); + + assert_eq!(stack.last(), Some(&Value::string("async-process"))); +} + +#[test] +fn io_implementations_do_not_create_private_threads_or_runtimes() { + let async_source = include_str!("../../src/builtins/runtime/io/async_io.rs"); + let blocking_source = include_str!("../../src/builtins/runtime/io/blocking.rs"); + + // The async implementation must run on the bridge's executor: it must + // not spawn its own threads or build its own tokio runtime. + assert!(!async_source.contains("thread::Builder")); + assert!(!async_source.contains("runtime::Builder")); + assert!(!async_source.contains("spawn_blocking")); + // The blocking implementation must not create a private runtime either; + // per-op worker threads are driven by the blocking path itself. + assert!(!blocking_source.contains("runtime::Builder")); +} + +#[cfg(unix)] +struct ProcessGroupGuard { + parent: Option, + descendant: Option, +} + +#[cfg(unix)] +impl Drop for ProcessGroupGuard { + fn drop(&mut self) { + if let Some(parent) = self.parent + && let Ok(parent) = libc::pid_t::try_from(parent) + { + unsafe { + libc::kill(-parent, libc::SIGKILL); + } + } + if let Some(descendant) = self.descendant + && let Ok(descendant) = libc::pid_t::try_from(descendant) + { + unsafe { + libc::kill(descendant, libc::SIGKILL); + } + } + } +} + +#[cfg(unix)] +fn wait_for_file(path: &std::path::Path) -> String { + for _ in 0..200 { + if let Ok(contents) = std::fs::read_to_string(path) + && !contents.trim().is_empty() + { + return contents; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("timed out waiting for {}", path.display()); +} + +#[cfg(unix)] +fn pid_is_running(pid: u32) -> bool { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return false; + }; + let stat_path = std::path::PathBuf::from(format!("/proc/{pid}/stat")); + if std::fs::read_to_string(stat_path).is_err() { + return false; + } + let result = unsafe { libc::kill(pid, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(unix)] +fn wait_for_pid_exit(pid: u32) -> bool { + for _ in 0..200 { + if !pid_is_running(pid) { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + false +} + +#[cfg(unix)] +fn process_tree_command( + parent_path: &std::path::Path, + descendant_path: &std::path::Path, + marker_path: &std::path::Path, +) -> String { + format!( + "echo $$ > '{}'; sh -c 'while :; do sleep 30; done' '{}-worker' & child=$!; echo $child > '{}'; (sleep 1; echo survived > '{}') & wait", + parent_path.display(), + marker_path.display(), + descendant_path.display(), + marker_path.display() + ) +} + +#[cfg(unix)] +fn guest_popen_program(command: &str, expression: &str) -> String { + format!(r#"let h = io::popen("{command}", "r"); {expression}"#) +} + +#[cfg(unix)] +#[test] +fn async_io_reset_kills_and_reaps_the_entire_popen_process_group() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow Unix epoch") + .as_nanos(); + let base = std::env::temp_dir().join(format!( + "pd-vm-async-reset-tree-{}-{nonce}", + std::process::id() + )); + let parent_path = base.with_extension("parent"); + let descendant_path = base.with_extension("descendant"); + let marker_path = base.with_extension("marker"); + let command = process_tree_command(&parent_path, &descendant_path, &marker_path); + let source = guest_popen_program(&command, "io::read_all(h);"); + + let compiled = compile_source(&format!("use io;\n{source}")).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + super::async_test_bridge::install(&mut vm); + assert!(matches!( + vm.run().expect("run should start"), + VmStatus::Waiting(_) + )); + vm.wait_for_host_op_blocking() + .expect("popen should complete before read starts"); + assert!(matches!( + vm.resume().expect("read should start"), + VmStatus::Waiting(_) + )); + + let parent_pid = wait_for_file(&parent_path) + .trim() + .parse::() + .expect("parent pid"); + let descendant_pid = wait_for_file(&descendant_path) + .trim() + .parse::() + .expect("descendant pid"); + let _guard = ProcessGroupGuard { + parent: Some(parent_pid), + descendant: Some(descendant_pid), + }; + + let _ = vm.reset_for_reuse(); + assert!(vm.execution_scope().resources().is_empty()); + assert!(vm.execution_scope().operations().is_empty()); + std::thread::sleep(std::time::Duration::from_millis(1_200)); + assert!( + !marker_path.exists(), + "a killed process group must not run descendants" + ); + assert!( + wait_for_pid_exit(parent_pid), + "the popen parent must be gone" + ); + assert!( + wait_for_pid_exit(descendant_pid), + "the popen descendant must be gone" + ); + + let _ = std::fs::remove_file(parent_path); + let _ = std::fs::remove_file(descendant_path); + let _ = std::fs::remove_file(marker_path); +} + +#[cfg(unix)] +#[test] +fn async_io_failed_resource_handoff_cleans_up_the_popen_process_group() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow Unix epoch") + .as_nanos(); + let base = std::env::temp_dir().join(format!( + "pd-vm-async-handoff-tree-{}-{nonce}", + std::process::id() + )); + let parent_path = base.with_extension("parent"); + let descendant_path = base.with_extension("descendant"); + let marker_path = base.with_extension("marker"); + let command = process_tree_command(&parent_path, &descendant_path, &marker_path); + let source = guest_popen_program(&command, "h;"); + + let compiled = compile_source(&format!("use io;\n{source}")).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + super::async_test_bridge::install(&mut vm); + assert!(matches!( + vm.run().expect("run should start"), + VmStatus::Waiting(_) + )); + vm.execution_scope() + .begin_close(vm::ResourceCloseReason::VmReset) + .expect("scope close should start"); + let error = vm + .wait_for_host_op_blocking() + .expect_err("resource insertion into a closing scope must fail"); + assert!( + error.to_string().contains("resource insert") + || error.to_string().contains("scope") + || error.to_string().contains("closing"), + "unexpected failed-handoff error: {error}" + ); + + std::thread::sleep(std::time::Duration::from_millis(1_200)); + assert!( + !marker_path.exists(), + "failed handoff must not leave a live descendant" + ); + + let _ = std::fs::remove_file(parent_path); + let _ = std::fs::remove_file(descendant_path); + let _ = std::fs::remove_file(marker_path); +} diff --git a/tests/builtins/io_builtin_edge_tests.rs b/tests/builtins/io_builtin_edge_tests.rs index 0e58dc0c..f92241fd 100644 --- a/tests/builtins/io_builtin_edge_tests.rs +++ b/tests/builtins/io_builtin_edge_tests.rs @@ -1,4 +1,4 @@ -use vm::{Value, Vm, VmError, VmStatus, compile_source}; +use vm::{IoHostExt, Value, Vm, VmError, VmStatus, compile_source}; fn run_source(source: &str) -> Result, VmError> { let wrapped = format!("use io;\n{source}"); @@ -119,3 +119,129 @@ fn io_flush_on_read_handle_is_a_noop_true() { .expect("program should execute"); assert_eq!(stack.last(), Some(&Value::Bool(true))); } + +#[cfg(unix)] +#[test] +fn io_policy_denies_process_launch_when_process_capability_is_disabled() { + let compiled = compile_source( + r#" + use io; + io::popen("exit 0", "r"); + "#, + ) + .expect("source should compile"); + let mut registry = vm::HostFunctionRegistry::restricted(); + registry.set_capability_profile( + vm::CapabilityProfile::builder() + .allow_builtin(vm::BuiltinFunction::IoPopen) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(vm::IoPolicy::default()); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + let error = vm.run().expect_err("process launch should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("process capability"))); +} + +#[test] +fn io_policy_denies_paths_outside_allowed_roots() { + let compiled = compile_source( + r#" + use io; + io::exists("Cargo.toml"); + "#, + ) + .expect("source should compile"); + let mut registry = vm::HostFunctionRegistry::restricted(); + registry.set_capability_profile( + vm::CapabilityProfile::builder() + .allow_builtin(vm::BuiltinFunction::IoExists) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(vm::IoPolicy::default()); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + let error = vm.run().expect_err("path should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("allowed roots"))); +} + +#[test] +fn restricted_registry_defaults_to_deny_when_io_host_state_is_absent() { + let compiled = compile_source( + r#" + use io; + io::exists("Cargo.toml"); + "#, + ) + .expect("source should compile"); + let mut registry = vm::HostFunctionRegistry::restricted(); + registry.set_capability_profile( + vm::CapabilityProfile::builder() + .allow_builtin(vm::BuiltinFunction::IoExists) + .build(), + ); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + let error = vm + .run() + .expect_err("missing IO host state should use the deny-by-default policy"); + assert!(matches!(error, VmError::HostError(message) if message.contains("allowed roots"))); +} + +#[test] +fn io_policy_limits_write_size() { + let path = std::env::temp_dir().join(format!( + "pd-vm-policy-write-limit-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should follow Unix epoch") + .as_nanos() + )); + let compiled = compile_source(&format!( + r#" + use io; + let handle = io::open("{}", "w"); + io::write(handle, "four"); + "#, + path.display() + )) + .expect("source should compile"); + let policy = vm::IoPolicy { + allowed_roots: vec![std::env::temp_dir().display().to_string()], + allow_write: true, + max_write_bytes: 3, + ..vm::IoPolicy::default() + }; + let mut registry = vm::HostFunctionRegistry::restricted(); + registry.set_capability_profile( + vm::CapabilityProfile::builder() + .allow_builtin(vm::BuiltinFunction::IoOpen) + .allow_builtin(vm::BuiltinFunction::IoWrite) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(policy); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + assert!(matches!( + vm.run().expect("open should start"), + VmStatus::Waiting(_) + )); + vm.wait_for_host_op_blocking() + .expect("open should complete"); + let error = vm.resume().expect_err("oversized write should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("write limit"))); + let _ = std::fs::remove_file(path); +} diff --git a/tests/builtins/io_scope_lifecycle_tests.rs b/tests/builtins/io_scope_lifecycle_tests.rs index 3632a4c3..739a5ec7 100644 --- a/tests/builtins/io_scope_lifecycle_tests.rs +++ b/tests/builtins/io_scope_lifecycle_tests.rs @@ -288,16 +288,70 @@ fn pending_io_operation_can_be_cancelled_through_scope() { vm.execution_scope().operations().is_empty(), "polling the cancelled operation must release it exactly once" ); +} + +#[cfg(unix)] +struct ProcessTreeCleanup { + leader: i32, + descendant: i32, + marker: std::path::PathBuf, +} + +#[cfg(unix)] +impl Drop for ProcessTreeCleanup { + fn drop(&mut self) { + unsafe { + libc::kill(-self.leader, libc::SIGKILL); + libc::kill(self.descendant, libc::SIGKILL); + } + let _ = std::fs::remove_file(&self.marker); + } +} + +#[cfg(unix)] +fn process_is_running(pid: i32) -> bool { + let path = format!("/proc/{pid}/stat"); + let Ok(stat) = std::fs::read_to_string(path) else { + return false; + }; + let Some((_, state)) = stat.split_once(") ") else { + return true; + }; + !state.starts_with('Z') +} - // Cancelling the read must also retire the underlying child process so no - // orphaned `sleep 30` survives the test. - wait_for_child_exit(); +#[cfg(unix)] +fn wait_for_process_exit(pid: i32) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while process_is_running(pid) { + assert!( + std::time::Instant::now() < deadline, + "popen descendant remained alive after reset" + ); + std::thread::yield_now(); + } } -/// Best-effort wait so a cancelled child process has time to be reaped before -/// the test process exits (the driver kills it on cancel). -fn wait_for_child_exit() { - std::thread::sleep(std::time::Duration::from_millis(100)); +#[cfg(unix)] +fn read_process_marker(path: &std::path::Path) -> (i32, i32) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if let Ok(contents) = std::fs::read_to_string(path) { + let values = contents + .split_whitespace() + .map(str::parse::) + .collect::, _>>() + .expect("popen marker should contain process ids"); + if values.len() == 2 { + return (values[0], values[1]); + } + } + assert!( + std::time::Instant::now() < deadline, + "popen test child did not publish its process marker" + ); + std::thread::yield_now(); + } } // ------------------------------------------------ reset / drop retirement @@ -314,7 +368,7 @@ fn reset_for_reuse_joins_pending_io_worker() { )); assert_eq!(vm.execution_scope().operations().len(), 1); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert!(vm.execution_scope().operations().is_empty()); assert!(vm.execution_scope().resources().is_empty()); } @@ -327,7 +381,7 @@ fn reset_for_reuse_retires_io_resources_through_scope() { "open leaves a live IO resource in the scope" ); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert!( vm.execution_scope().resources().is_empty() && vm.execution_scope().operations().is_empty(), @@ -344,3 +398,44 @@ fn drop_retires_io_resources_through_scope() { assert!(!vm.execution_scope().resources().is_empty()); drop(vm); } + +#[cfg(unix)] +#[test] +fn reset_for_reuse_terminates_live_popen_process_tree() { + static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0); + let suffix = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let marker = std::env::temp_dir().join(format!( + "pd-vm-blocking-io-reset-{0}-{suffix}.marker", + std::process::id() + )); + let command = format!( + "parent=$$; sleep 30 & child=$!; printf '%s %s' $parent $child > {}; wait $child", + marker.display() + ); + let source = format!("use io;\nlet h = io::popen(\"{command}\", \"r\");\nh;"); + let compiled = compile_source(&source).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + let mut status = vm.run().expect("run should start"); + loop { + match status { + VmStatus::Halted => break, + VmStatus::Yielded => status = vm.resume().expect("resume should continue"), + VmStatus::Waiting(_) => { + vm.wait_for_host_op_blocking() + .expect("waiting op should finish"); + status = vm.resume().expect("resume should continue"); + } + } + } + let (leader, descendant) = read_process_marker(&marker); + let _cleanup = ProcessTreeCleanup { + leader, + descendant, + marker: marker.clone(), + }; + + let _ = vm.reset_for_reuse(); + assert!(vm.execution_scope().resources().is_empty()); + wait_for_process_exit(descendant); + let _ = std::fs::remove_file(marker); +} diff --git a/tests/builtins/sqlite_scope_lifecycle_tests.rs b/tests/builtins/sqlite_scope_lifecycle_tests.rs index ce0c00eb..8c8a395c 100644 --- a/tests/builtins/sqlite_scope_lifecycle_tests.rs +++ b/tests/builtins/sqlite_scope_lifecycle_tests.rs @@ -298,7 +298,7 @@ fn sqlite_close_cancels_siblings_and_reset_retires_all() { matches!(status, VmStatus::Waiting(_)), "long query should leave the VM waiting, got: {status:?}" ); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert!( vm.execution_scope().operations().is_empty(), "reset must retire all pending sqlite operations" diff --git a/tests/builtins/stdlib_tests.rs b/tests/builtins/stdlib_tests.rs index cc1674f1..d6df463d 100644 --- a/tests/builtins/stdlib_tests.rs +++ b/tests/builtins/stdlib_tests.rs @@ -14,6 +14,8 @@ fn run_rustscript_spec(path: &Path) -> Vec { ); let mut vm = Vm::new(compiled.program); + #[cfg(feature = "async")] + super::async_test_bridge::install(&mut vm); loop { let status = vm.run().expect("spec vm should run"); match status { diff --git a/tests/builtins_tests.rs b/tests/builtins_tests.rs index 204b341f..44eade8d 100644 --- a/tests/builtins_tests.rs +++ b/tests/builtins_tests.rs @@ -1,11 +1,21 @@ #![cfg(feature = "runtime")] +#[cfg(feature = "async")] +#[path = "support/async_test_bridge.rs"] +mod async_test_bridge; + +#[cfg(not(feature = "async"))] #[path = "builtins/io_builtin_edge_tests.rs"] mod io_builtin_edge_tests; +#[cfg(all(not(feature = "async"), not(target_arch = "wasm32")))] #[path = "builtins/io_scope_lifecycle_tests.rs"] mod io_scope_lifecycle_tests; +#[cfg(feature = "async")] +#[path = "builtins/io_async_tests.rs"] +mod io_async_tests; + #[cfg(feature = "sqlite")] #[path = "builtins/sqlite_scope_lifecycle_tests.rs"] mod sqlite_scope_lifecycle_tests; diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 024a2bf1..6b799520 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -87,11 +87,11 @@ fn rustscript_host_import_runtime_cases_work() { use runtime; runtime::sleep(41); "#, - vec![Value::Int(42)], + vec![Value::Bool(true)], ), bindings: vec![HostBindingCase { name: "runtime::sleep", - factory: make_add_one, + factory: make_always_allow, }], }, BoundRuntimeCase { @@ -115,11 +115,11 @@ fn rustscript_host_import_runtime_cases_work() { use runtime; runtime::sleep(41); "#, - vec![Value::Int(42)], + vec![Value::Bool(true)], ), bindings: vec![HostBindingCase { name: "runtime::sleep", - factory: make_add_one, + factory: make_always_allow, }], }, BoundRuntimeCase { @@ -143,11 +143,11 @@ fn rustscript_host_import_runtime_cases_work() { use runtime as rt; rt::sleep(41); "#, - vec![Value::Int(42)], + vec![Value::Bool(true)], ), bindings: vec![HostBindingCase { name: "runtime::sleep", - factory: make_add_one, + factory: make_always_allow, }], }, ]; @@ -163,6 +163,8 @@ fn rustscript_io_namespace_builtin_calls_are_supported() { "#; let compiled = compile_source(source).expect("compile should succeed"); let mut vm = Vm::new(compiled.program); + #[cfg(feature = "async")] + super::async_test_bridge::install(&mut vm); loop { let status = vm.run().expect("vm should run"); @@ -532,6 +534,8 @@ fn compile_source_file_with_rustscript_complex_fixture() { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/example_complex.rss"); let compiled = compile_source_file(path.as_path()).expect("compile should succeed"); let mut vm = Vm::new(compiled.program); + #[cfg(feature = "async")] + super::async_test_bridge::install(&mut vm); for func in &compiled.functions { match func.name.as_str() { diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 11328072..d8ce5df8 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1,5 +1,9 @@ #![allow(clippy::duplicate_mod)] +#[cfg(feature = "async")] +#[path = "support/async_test_bridge.rs"] +mod async_test_bridge; + #[cfg(feature = "runtime")] #[path = "compiler/compiler_common_tests.rs"] mod compiler_common_tests; diff --git a/tests/external_host_extension_tests.rs b/tests/external_host_extension_tests.rs new file mode 100644 index 00000000..4e3f3622 --- /dev/null +++ b/tests/external_host_extension_tests.rs @@ -0,0 +1,33 @@ +//! Runs the genuinely separate host-extension fixture through the ordinary +//! integration-test path. + +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn external_host_extension_fixture_is_automated() { + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest = repo.join("tests/fixtures/external-host-extension/Cargo.toml"); + let target_dir = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("rustscript-external-host-extension-target")); + + let output = Command::new("cargo") + .arg("test") + .arg("--manifest-path") + .arg(&manifest) + .arg("--locked") + .arg("--all-targets") + .arg("--all-features") + .env("CARGO_TARGET_DIR", target_dir) + .env("CARGO_TERM_COLOR", "never") + .output() + .expect("cargo must be available to run the external fixture"); + + assert!( + output.status.success(), + "external host-extension fixture failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/fixtures/external-host-extension/.gitignore b/tests/fixtures/external-host-extension/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/tests/fixtures/external-host-extension/.gitignore @@ -0,0 +1 @@ +/target diff --git a/tests/fixtures/external-host-extension/Cargo.lock b/tests/fixtures/external-host-extension/Cargo.lock new file mode 100644 index 00000000..47a051ae --- /dev/null +++ b/tests/fixtures/external-host-extension/Cargo.lock @@ -0,0 +1,321 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "external-host-extension" +version = "0.0.0" +dependencies = [ + "pd-host-function", + "pd-vm", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pd-host-function" +version = "0.1.0" +dependencies = [ + "pd-host-schema", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pd-host-schema" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "pd-vm" +version = "0.1.0" +dependencies = [ + "base64", + "futures-channel", + "libc", + "paste", + "pd-host-function", + "pd-host-schema", + "regex", + "rt-format", + "self_cell", + "serde", + "serde_json", + "syn 2.0.119", + "windows-sys", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rt-format" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45087cee619d316fa4bd1675494acff4a5eaa0892fa53bc364bd246f13e452e2" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/fixtures/external-host-extension/Cargo.toml b/tests/fixtures/external-host-extension/Cargo.toml new file mode 100644 index 00000000..9280dd23 --- /dev/null +++ b/tests/fixtures/external-host-extension/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "external-host-extension" +version = "0.0.0" +edition = "2024" +publish = false + +# Standalone fixture crate that consumes only the PUBLIC host-extension SDK of +# pd-vm. It is deliberately NOT a member of the pd-vm workspace: this proves +# the extension surface (HostContext module state / resource insert / operation +# start, HostExtension register/install, external resource/operation SDK, and +# the host-API catalog) works from a genuinely separate crate with no +# crate-private access. The empty [workspace] table detaches it from the +# enclosing pd-vm workspace so `cargo check --manifest-path` treats it as its +# own root. +[workspace] + +[dependencies] +vm = { package = "pd-vm", path = "../../..", default-features = false, features = ["runtime"] } +pd-host-function = { path = "../../../pd-host-function" } \ No newline at end of file diff --git a/tests/fixtures/external-host-extension/src/lib.rs b/tests/fixtures/external-host-extension/src/lib.rs new file mode 100644 index 00000000..8849854c --- /dev/null +++ b/tests/fixtures/external-host-extension/src/lib.rs @@ -0,0 +1,712 @@ +//! External host-extension fixture. +//! +//! A standalone crate that consumes only the **public** host-extension SDK of +//! `pd-vm` (crate name `vm`): the [`HostApiCatalog`] model, typed per-VM +//! module state through [`HostContext`], the [`HostExtension`] register / +//! install lifecycle, external [`HostResource`] insertion and typed borrows +//! through the generic host boundary, and external concrete +//! [`HostOperation`] drivers started into the VM's execution scope. +//! +//! It is deliberately **not** a member of the pd-vm workspace: this proves +//! the extension surface works from a genuinely separate crate with no +//! crate-private access. + +use std::sync::Arc; +#[cfg(test)] +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[cfg(test)] +use vm::HostContextErrorKind; +use pd_host_function::pd_host_function; + +#[cfg(test)] +use vm::{BytecodeBuilder, HostImport}; +use vm::{ + CallOutcome, HostApiCatalog, HostContextError, HostExtension, HostFunctionRegistry, + HostParamPassing, HostTypeSchema, ResourceTypeKey, ResourceTypeSchema, Value, Vm, VmError, + VmResult, arg, catalog_import_schemas, resource, return_one, +}; +use vm::host_api; + +/// Number of times the external `Counter` resource was closed. +pub static CLOSED_COUNTERS: AtomicUsize = AtomicUsize::new(0); +/// Number of times the external `Widget` resource was closed. +pub static CLOSED_WIDGETS: AtomicUsize = AtomicUsize::new(0); +/// Number of calls that reached the macro-generated keyed handlers. +pub static MACRO_HANDLER_CALLS: AtomicUsize = AtomicUsize::new(0); + +/// Serializes tracker-dependent tests (the close counters are process-global). +#[cfg(test)] +static TRACKER_LOCK: Mutex<()> = Mutex::new(()); + +#[cfg(test)] +fn reset_trackers() { + CLOSED_COUNTERS.store(0, Ordering::SeqCst); + CLOSED_WIDGETS.store(0, Ordering::SeqCst); +} + +/// A typed external resource: closed through the generic poll-based close +/// contract, identified by a catalog `ResourceTypeKey`. +#[derive(Debug)] +pub struct Counter(pub u64); + +impl resource::HostResource for Counter { + fn resource_type_key() -> Option { + Some(ResourceTypeKey::new("demo.counter").expect("static key")) + } + + fn begin_close( + &mut self, + _reason: resource::ResourceCloseReason, + ) -> resource::ResourceResult { + CLOSED_COUNTERS.fetch_add(1, Ordering::SeqCst); + Ok(resource::CloseProgress::Ready) + } +} + +/// A second typed external resource with its own key. +#[derive(Debug)] +pub struct Widget(pub i64); + +impl resource::HostResource for Widget { + fn resource_type_key() -> Option { + Some(ResourceTypeKey::new("demo.widget").expect("static key")) + } + + fn begin_close( + &mut self, + _reason: resource::ResourceCloseReason, + ) -> resource::ResourceResult { + CLOSED_WIDGETS.fetch_add(1, Ordering::SeqCst); + Ok(resource::CloseProgress::Ready) + } +} + +/// Persistent per-VM module state: survives execution-scope reset and never +/// participates in resource close. Covered by `HostModule`'s blanket impl. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DemoPolicy { + pub max_counters: u64, +} + +/// An external concrete [`HostOperation`] driver. Polling advances the +/// operation; cancellation records the reason and completes promptly. +#[derive(Debug)] +pub struct CounterOp { + pub remaining: u64, + pub cancelled: Arc, +} + +impl vm::operation::HostOperation for CounterOp { + fn poll( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + if self.remaining == 0 { + std::task::Poll::Ready(Ok(())) + } else { + self.remaining -= 1; + std::task::Poll::Pending + } + } + + fn cancel( + &mut self, + _reason: vm::operation::OperationCancelReason, + ) -> vm::operation::OperationResult<()> { + self.cancelled.fetch_add(1, Ordering::SeqCst); + self.remaining = 0; + Ok(()) + } +} + +// ---- catalog --------------------------------------------------------------- + +/// The external extension's catalog: one resource key per concrete type and +/// one declared function per registered host callable. +pub fn demo_catalog() -> Arc { + let mut builder = HostApiCatalog::builder(); + builder.resource(ResourceTypeSchema::new( + ResourceTypeKey::new("demo.counter").expect("key"), + "An external counter resource", + )); + builder.resource(ResourceTypeSchema::new( + ResourceTypeKey::new("demo.widget").expect("key"), + "An external widget resource", + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::make_counter", + vec![vm::HostParamSchema::value("seed", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::make_widget", + vec![vm::HostParamSchema::value("seed", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::read_counter", + vec![vm::HostParamSchema::with_passing( + "handle", + HostTypeSchema::Resource(ResourceTypeKey::new("demo.counter").expect("key")), + HostParamPassing::Borrow, + )], + HostTypeSchema::Int, + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::spawn_op", + vec![], + HostTypeSchema::Int, + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::overloaded", + vec![vm::HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::overloaded", + vec![vm::HostParamSchema::value("value", HostTypeSchema::String)], + HostTypeSchema::String, + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::macro_borrow_counter", + vec![vm::HostParamSchema::with_passing( + "counter", + HostTypeSchema::Resource(ResourceTypeKey::new("demo.counter").expect("key")), + HostParamPassing::Borrow, + )], + HostTypeSchema::Int, + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::macro_take_counter", + vec![vm::HostParamSchema::with_passing( + "counter", + HostTypeSchema::Resource(ResourceTypeKey::new("demo.counter").expect("key")), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Int, + )); + Arc::new(builder.build().expect("catalog must build")) +} + +fn decode_handle(raw: i64) -> Result { + resource::ResourceHandle::from_raw(raw as u64) + .map_err(|error| VmError::HostError(error.to_string())) +} + +fn host_error(error: HostContextError) -> VmError { + VmError::HostError(error.to_string()) +} + +/// External host function: inserts a `Counter` into the VM's execution scope +/// and returns its raw handle to the guest. +fn make_counter(vm: &mut Vm, args: &[Value]) -> VmResult { + let seed = match args.first() { + Some(Value::Int(seed)) => *seed, + _ => return Err(VmError::TypeMismatch("int seed")), + }; + let token = vm + .host_context() + .push_resource(Counter(seed as u64)) + .map_err(host_error)?; + Ok(CallOutcome::Return(return_one(token.handle().raw() as i64))) +} + +/// External host function: inserts a `Widget` into the scope. +fn make_widget(vm: &mut Vm, args: &[Value]) -> VmResult { + let seed = match args.first() { + Some(Value::Int(seed)) => *seed, + _ => return Err(VmError::TypeMismatch("int seed")), + }; + let token = vm + .host_context() + .push_resource(Widget(seed)) + .map_err(host_error)?; + Ok(CallOutcome::Return(return_one(token.handle().raw() as i64))) +} + +/// External host function: borrows a `Counter` through the typed host +/// boundary and reads its value. +fn read_counter(vm: &mut Vm, args: &[Value]) -> VmResult { + let raw = match args.first() { + Some(Value::Int(raw)) => *raw, + _ => return Err(VmError::TypeMismatch("int handle")), + }; + let decoded = decode_handle(raw)?; + let value = vm + .host_context() + .borrow_resource::(decoded) + .map_err(host_error)? + .0; + Ok(CallOutcome::Return(return_one(value as i64))) +} + +/// External host function: starts a concrete [`HostOperation`] driver in the +/// VM's execution scope and returns a non-zero id. +fn spawn_op(vm: &mut Vm, _args: &[Value]) -> VmResult { + let cancelled = Arc::new(AtomicUsize::new(0)); + let spec = vm::operation::OperationSpec::new(CounterOp { + remaining: 2, + cancelled: Arc::clone(&cancelled), + }); + let id = vm + .host_context() + .start_operation(spec) + .map_err(host_error)?; + Ok(CallOutcome::Return(return_one(id.raw() as i64))) +} + +mod macro_functions_parent { + use super::*; + + pub mod functions { + use super::*; + + /// External proc-macro function with a matching borrowed resource key. + #[pd_host_function(name = "demo::macro_borrow_counter")] + pub fn macro_borrow_counter( + #[pd_host_resource(passing = "borrow", key = "demo.counter")] + counter: resource::ResourceRef<'_, Counter>, + ) -> VmResult { + MACRO_HANDLER_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(counter.0 as i64) + } + + /// External proc-macro function with a matching owned resource key. + #[pd_host_function(name = "demo::macro_take_counter")] + pub fn macro_take_counter( + #[pd_host_resource(passing = "take_owned", key = "demo.counter")] + counter: resource::ResourceOwned, + ) -> VmResult { + MACRO_HANDLER_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(counter.into_inner().0 as i64) + } + + /// Deliberately advertises a key different from `Counter`'s concrete key. + #[pd_host_function(name = "demo::macro_borrow_counter_wrong_key")] + pub fn macro_borrow_counter_wrong_key( + #[pd_host_resource(passing = "borrow", key = "demo.wrong")] + counter: resource::ResourceRef<'_, Counter>, + ) -> VmResult { + MACRO_HANDLER_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(counter.0 as i64) + } + + /// Deliberately advertises a wrong key on an owned-resource path. + #[pd_host_function(name = "demo::macro_take_counter_wrong_key")] + pub fn macro_take_counter_wrong_key( + #[pd_host_resource(passing = "take_owned", key = "demo.wrong")] + counter: resource::ResourceOwned, + ) -> VmResult { + MACRO_HANDLER_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(counter.into_inner().0 as i64) + } + } +} + +fn macro_borrow_counter(vm: &mut Vm, args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(return_one( + macro_functions_parent::functions::macro_borrow_counter(vm, args)?, + ))) +} + +fn macro_take_counter(vm: &mut Vm, args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(return_one( + macro_functions_parent::functions::macro_take_counter(vm, args)?, + ))) +} + +fn overloaded_int(_vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(return_one(101_i64))) +} + +fn overloaded_string(_vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(return_one("string overload"))) +} +fn register_from_catalog( + registry: &mut HostFunctionRegistry, + catalog: &HostApiCatalog, + name: &str, + function: vm::StaticHostFunction, +) -> VmResult<()> { + // Preserve the complete selected schema and let the SDK validate it before + // mutating the registry. The helper intentionally does not select an + // overload by position. + let schemas = catalog_import_schemas(catalog, name); + let [schema] = schemas.as_slice() else { + return Err(VmError::HostError(format!( + "expected exactly one catalog schema for '{name}'" + ))); + }; + vm::register_catalog_static_function(registry, catalog, name, (*schema).clone(), function) + .map_err(|error| VmError::HostError(error.to_string())) +} + +fn register_overloads_from_catalog( + registry: &mut HostFunctionRegistry, + catalog: &HostApiCatalog, +) -> VmResult<()> { + let schemas = catalog_import_schemas(catalog, "demo::overloaded"); + if schemas.len() != 2 { + return Err(VmError::HostError( + "expected two overload schemas for 'demo::overloaded'".to_string(), + )); + } + for schema in schemas { + let function = match schema.params.first().map(|param| ¶m.schema) { + Some(HostTypeSchema::Int) => overloaded_int as vm::StaticHostFunction, + Some(HostTypeSchema::String) => overloaded_string as vm::StaticHostFunction, + _ => { + return Err(VmError::HostError( + "unexpected overload parameter schema".to_string(), + )); + } + }; + vm::register_catalog_static_function( + registry, + catalog, + "demo::overloaded", + schema, + function, + ) + .map_err(|error| VmError::HostError(error.to_string()))?; + } + Ok(()) +} + +/// External host extension: registers host functions and installs persistent +/// per-VM module state through the public [`HostExtension`] surface. +pub struct DemoExtension; + +impl HostExtension for DemoExtension { + fn register(&self, registry: &mut HostFunctionRegistry) -> VmResult<()> { + let catalog = demo_catalog(); + register_from_catalog(registry, &catalog, "demo::make_counter", make_counter)?; + register_from_catalog(registry, &catalog, "demo::make_widget", make_widget)?; + register_from_catalog(registry, &catalog, "demo::read_counter", read_counter)?; + register_from_catalog(registry, &catalog, "demo::spawn_op", spawn_op)?; + register_overloads_from_catalog(registry, &catalog)?; + register_from_catalog( + registry, + &catalog, + "demo::macro_borrow_counter", + macro_borrow_counter, + )?; + register_from_catalog( + registry, + &catalog, + "demo::macro_take_counter", + macro_take_counter, + )?; + Ok(()) + } + + fn install(&self, vm: &mut Vm) { + let mut context = vm.host_context(); + context.set_module_state(DemoPolicy { max_counters: 3 }); + } +} + +// ---- tests ---------------------------------------------------------------- + +#[cfg(test)] +fn installed_vm() -> Vm { + let program = vm::Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + vm.install_extension(&DemoExtension) + .expect("extension should install"); + vm +} + +#[test] +fn external_scope_state_api_is_typed_and_scope_local() { + let mut vm = installed_vm(); + assert_eq!( + vm.host_context().scope_phase(), + vm::execution_scope::ScopeState::Active + ); + + { + let mut context = vm.host_context(); + let state = context + .scope_state_or_insert_with(|| 10_u64) + .expect("insert typed scope state"); + *state += 2; + } + assert_eq!(vm.host_context().scope_state::(), Some(&12)); + + *vm + .host_context() + .scope_state_mut::() + .expect("mutable typed scope state") += 5; + assert_eq!(vm.host_context().scope_state::(), Some(&17)); + assert_eq!(vm.host_context().take_scope_state::(), Some(17)); + assert_eq!(vm.host_context().scope_state::(), None); + assert_eq!( + vm.host_context().scope_phase(), + vm::execution_scope::ScopeState::Active + ); +} + +#[test] +fn extension_installs_module_state_and_registers_host_functions() { + let mut vm = installed_vm(); + // The infallible install phase registered typed per-VM module state. + assert_eq!( + vm.host_context() + .module_state::() + .map(|policy| policy.max_counters), + Some(3) + ); + // The catalog-derived registration surface is exercised by register(). + assert!(vm.host_context().is_scope_active()); + drop(vm); +} + +#[test] +fn external_resource_insert_borrow_and_close_through_scope() { + let _tracker_guard = TRACKER_LOCK.lock().unwrap(); + reset_trackers(); + let mut vm = installed_vm(); + let counter = { + let token = vm + .host_context() + .push_resource(Counter(7)) + .expect("insert counter"); + token + }; + assert_eq!(vm.host_context().resource_count(), 1); + + // Typed borrow reads the value through the SDK (borrow is call-scoped). + { + let context = vm.host_context(); + let borrowed = context + .borrow_resource::(counter.handle()) + .expect("borrow counter"); + assert_eq!(borrowed.0, 7); + } + + // Mut borrow writes through the SDK. + { + let mut context = vm.host_context(); + let mut borrowed = context + .borrow_resource_mut::(counter.handle()) + .expect("mut borrow counter"); + borrowed.0 = 9; + } + { + let context = vm.host_context(); + let borrowed = context + .borrow_resource::(counter.handle()) + .expect("re-borrow counter"); + assert_eq!(borrowed.0, 9); + } + + // Vm drop closes the resource through the scope close sweep. + drop(vm); + assert_eq!(CLOSED_COUNTERS.load(Ordering::SeqCst), 1); +} + +#[test] +fn typed_wrong_resource_rejection_is_structured() { + let _tracker_guard = TRACKER_LOCK.lock().unwrap(); + reset_trackers(); + let mut vm = installed_vm(); + let token = vm + .host_context() + .push_resource(Widget(2)) + .expect("insert widget"); + + // Wrong concrete type is rejected with a structured resource error. + let error = vm + .host_context() + .borrow_resource::(token.handle()) + .unwrap_err(); + assert_eq!(error.namespace(), "host::resource"); + assert!(matches!( + error.kind(), + HostContextErrorKind::Resource(resource_error) + if resource_error.code() == resource::ResourceErrorCode::ResourceTypeMismatch + )); + drop(vm); +} + +#[test] +fn reset_driven_scope_cleanup_closes_resources_and_cancels_operations() { + let _tracker_guard = TRACKER_LOCK.lock().unwrap(); + reset_trackers(); + let mut vm = installed_vm(); + vm.host_context() + .push_resource(Counter(1)) + .expect("counter"); + vm.host_context().push_resource(Widget(2)).expect("widget"); + let cancelled = Arc::new(AtomicUsize::new(0)); + let spec = vm::operation::OperationSpec::new(CounterOp { + remaining: 200, + cancelled: Arc::clone(&cancelled), + }); + vm.host_context().start_operation(spec).expect("op start"); + assert_eq!(vm.host_context().resource_count(), 2); + assert_eq!(vm.host_context().operation_count(), 1); + + // Reset drives the scope to quiescence: resources close, op cancels. + let _ = vm.reset_for_reuse(); + assert_eq!(vm.host_context().resource_count(), 0); + assert_eq!(vm.host_context().operation_count(), 0); + assert_eq!(CLOSED_COUNTERS.load(Ordering::SeqCst), 1); + assert_eq!(CLOSED_WIDGETS.load(Ordering::SeqCst), 1); + assert!( + cancelled.load(Ordering::SeqCst) > 0, + "the pending operation driver must be cancelled by the scope close" + ); +} + +#[test] +fn module_state_survives_reset_and_never_participates_in_close() { + let _tracker_guard = TRACKER_LOCK.lock().unwrap(); + reset_trackers(); + let mut vm = installed_vm(); + let _ = vm.reset_for_reuse(); + assert!( + vm.host_context().module_state::().is_some(), + "module state must survive reset" + ); + assert_eq!(CLOSED_COUNTERS.load(Ordering::SeqCst), 0); + assert_eq!(CLOSED_WIDGETS.load(Ordering::SeqCst), 0); + drop(vm); +} + +#[test] +fn catalog_validates_declarations_and_fingerprint() { + let catalog = demo_catalog(); + assert!(catalog.has_resource(&ResourceTypeKey::new("demo.counter").expect("key"))); + assert!(catalog.has_resource(&ResourceTypeKey::new("demo.widget").expect("key"))); + + let make_counter = catalog_import_schemas(&catalog, "demo::make_counter"); + assert_eq!(make_counter.len(), 1); + assert_eq!(make_counter[0].params.len(), 1); + assert_eq!(make_counter[0].params[0].name, "seed"); + assert_eq!(make_counter[0].params[0].schema, HostTypeSchema::Int); + assert_eq!(make_counter[0].params[0].passing, HostParamPassing::Value); + assert_eq!(make_counter[0].return_type, HostTypeSchema::Int); + + let read_counter = catalog_import_schemas(&catalog, "demo::read_counter"); + assert_eq!(read_counter.len(), 1); + assert_eq!(read_counter[0].params.len(), 1); + assert_eq!( + read_counter[0].params[0].schema, + HostTypeSchema::Resource(ResourceTypeKey::new("demo.counter").expect("key")) + ); + assert_eq!(read_counter[0].params[0].passing, HostParamPassing::Borrow); + assert_eq!(read_counter[0].return_type, HostTypeSchema::Int); + + let mut registry = HostFunctionRegistry::empty(); + DemoExtension + .register(&mut registry) + .expect("catalog-backed registration succeeds"); + assert!(registry.contains_name("demo::make_counter")); + assert!(registry.contains_name("demo::read_counter")); + + // Fingerprint is deterministic. + assert_eq!(catalog.fingerprint(), catalog.fingerprint()); +} + +#[test] +fn external_proc_macro_resource_keys_are_checked_before_handler_and_lifecycle_logic() { + let _tracker_guard = TRACKER_LOCK.lock().unwrap(); + reset_trackers(); + MACRO_HANDLER_CALLS.store(0, Ordering::SeqCst); + let mut vm = installed_vm(); + let token = vm + .host_context() + .push_resource(Counter(37)) + .expect("counter"); + let args = [Value::Int(token.handle().raw() as i64)]; + + assert_eq!( + macro_functions_parent::functions::macro_borrow_counter(&mut vm, &args) + .expect("matching borrowed key"), + 37 + ); + assert_eq!(MACRO_HANDLER_CALLS.load(Ordering::SeqCst), 1); + + let mismatch = macro_functions_parent::functions::macro_borrow_counter_wrong_key(&mut vm, &args) + .expect_err("wrong borrowed key must fail before handler"); + assert!(mismatch.to_string().contains("resource type key")); + assert_eq!(MACRO_HANDLER_CALLS.load(Ordering::SeqCst), 1); + assert_eq!(vm.host_context().resource_count(), 1); + + let owned_mismatch = + macro_functions_parent::functions::macro_take_counter_wrong_key(&mut vm, &args) + .expect_err("wrong owned key must fail before take"); + assert!(owned_mismatch.to_string().contains("resource type key")); + assert_eq!(MACRO_HANDLER_CALLS.load(Ordering::SeqCst), 1); + assert_eq!(vm.host_context().resource_count(), 1); + + assert_eq!( + macro_functions_parent::functions::macro_take_counter(&mut vm, &args) + .expect("matching owned key"), + 37 + ); + assert_eq!(MACRO_HANDLER_CALLS.load(Ordering::SeqCst), 2); + assert_eq!(vm.host_context().resource_count(), 0); +} + +#[test] +fn external_catalog_overloads_bind_by_schema_and_fingerprint() { + let catalog = demo_catalog(); + let schemas = catalog_import_schemas(&catalog, "demo::overloaded"); + assert_eq!(schemas.len(), 2); + assert!(schemas.iter().all(|schema| schema.fingerprint == catalog.fingerprint())); + assert!(schemas.iter().all(|schema| schema.name == "demo::overloaded")); + + let integer_schema = schemas + .iter() + .find(|schema| schema.params[0].schema == HostTypeSchema::Int) + .expect("integer overload") + .clone(); + let string_schema = schemas + .iter() + .find(|schema| schema.params[0].schema == HostTypeSchema::String) + .expect("string overload") + .clone(); + let mut bytecode = BytecodeBuilder::new(); + bytecode.ldc(0); + bytecode.call(0, 1); + bytecode.ldc(1); + bytecode.call(1, 1); + bytecode.ret(); + let program = vm::Program::with_imports_and_debug( + vec![Value::Int(4), Value::string("x")], + bytecode.finish(), + vec![ + HostImport { + name: "demo::overloaded".to_string(), + arity: 1, + return_type: vm::ValueType::Int, + }, + HostImport { + name: "demo::overloaded".to_string(), + arity: 1, + return_type: vm::ValueType::String, + }, + ], + None, + ) + .with_host_import_schemas(vec![integer_schema, string_schema]) + .expect("schema metadata aligns with imports"); + let mut vm = Vm::new(program); + let mut registry = HostFunctionRegistry::empty(); + DemoExtension + .register(&mut registry) + .expect("external extension registration"); + registry + .bind_vm_cached(&mut vm) + .expect("both overloads bind"); + assert_eq!(vm.run().expect("overloads execute"), vm::VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(101), Value::string("string overload")] + ); +} diff --git a/tests/host_binding_generation_tests.rs b/tests/host_binding_generation_tests.rs index e465a9dc..11bc0fe0 100644 --- a/tests/host_binding_generation_tests.rs +++ b/tests/host_binding_generation_tests.rs @@ -6,7 +6,10 @@ use build_script::{ HostBindingKind, HostExecutionKind, classify_host_binding, infer_host_execution, }; use syn::parse_quote; -use vm::{HostFunctionRegistry, JitConfig, JitTraceTerminal, Value, Vm, VmStatus, compile_source}; +use vm::{ + BuiltinFunction, CapabilityProfile, HostFunctionRegistry, JitConfig, JitTraceTerminal, Value, + Vm, VmStatus, compile_source, +}; fn native_jit_supported() -> bool { (cfg!(target_arch = "x86_64") @@ -15,6 +18,29 @@ fn native_jit_supported() -> bool { && (cfg!(target_os = "linux") || cfg!(target_os = "macos"))) } +#[test] +fn build_scanner_uses_the_shared_host_type_parser() { + let function: syn::ItemFn = parse_quote! { + fn inspect( + #[pd_host_resource(passing = "take_owned")] + resource: DemoResource, + optional: Option, + ) -> VmResult> { + unimplemented!() + } + }; + let params = build_script::parse_callable_params(&function); + assert_eq!(params.len(), 2); + assert_eq!(params[0].ty_label, "resource"); + assert!(!params[0].optional); + assert_eq!(params[1].ty_label, "int | null"); + assert!(params[1].optional); + assert_eq!( + pd_host_schema::type_label(&parse_quote!(VmResult>)).unwrap(), + "string | null" + ); +} + #[test] fn classifies_best_effort_host_bindings_from_signatures() { for function in [ @@ -140,6 +166,18 @@ fn infers_host_suspension_from_the_return_signature() { fn host() -> VmResult {} ); assert_eq!(infer_host_execution(&synchronous), HostExecutionKind::Sync); + + let asynchronous = parse_quote!( + async fn host(value: String) -> VmResult {} + ); + assert_eq!( + infer_host_execution(&asynchronous), + HostExecutionKind::MaySuspend + ); + assert_eq!( + classify_host_binding(&asynchronous), + HostBindingKind::StaticStack + ); } fn assert_runtime_sleep_loop_uses_native_host_call(bind_cached_registry: bool) { @@ -226,3 +264,100 @@ fn runtime_exit_still_halts_for_direct_and_cached_default_bindings() { assert!(vm.stack().is_empty()); } } + +#[test] +fn restricted_capabilities_disable_trace_jit_for_host_imports_and_builtins() { + for source in [ + r#" + use runtime; + let mut i = 0; + while i < 4 { + let _ = runtime::sleep(0); + i = i + 1; + } + i; + "#, + r#" + use re; + let mut i = 0; + while i < 4 { + let _ = re::match("a", "a"); + i = i + 1; + } + i; + "#, + ] { + let compiled = compile_source(source).expect("restricted loop should compile"); + let mut vm = Vm::new(compiled.program); + vm.set_jit_config(JitConfig { + enabled: native_jit_supported(), + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut vm) + .expect_err("restricted registry should reject ungranted capability during preflight"); + + assert!( + error + .to_string() + .contains("capability profile does not allow") + ); + assert_eq!(vm.jit_native_exec_count(), 0); + } +} + +#[test] +fn capability_profile_fingerprint_uses_stable_callable_identities() { + let first = CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::JsonEncode) + .allow_host_import("custom::echo") + .build(); + let reordered = CapabilityProfile::builder() + .allow_host_import("custom::echo") + .allow_builtin(BuiltinFunction::JsonEncode) + .build(); + + assert_eq!(first, reordered); + assert_eq!(first.fingerprint(), reordered.fingerprint()); + assert!(first.allows_builtin(BuiltinFunction::JsonEncode)); + assert!(first.allows_host_import("custom::echo")); + assert!(!first.allows_host_import("custom::other")); + assert_ne!( + first.fingerprint(), + CapabilityProfile::deny_all().fingerprint() + ); + assert_ne!( + CapabilityProfile::allow_all().fingerprint(), + CapabilityProfile::deny_all().fingerprint() + ); +} + +#[test] +fn vm_host_core_does_not_name_builtin_subsystem_policies() { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let host_runtime = std::fs::read_to_string(manifest.join("src/vm/host_runtime.rs")) + .expect("host runtime source"); + let capability = + std::fs::read_to_string(manifest.join("src/vm/capability.rs")).expect("capability source"); + let host = std::fs::read_to_string(manifest.join("src/vm/host.rs")).expect("host source"); + + for forbidden in [ + "HttpState", + "IoPolicy", + "SqlitePolicy", + "http_state", + "io_policy", + "sqlite_policy", + ] { + assert!( + !host_runtime.contains(forbidden), + "HostRuntime leaked {forbidden}" + ); + assert!( + !capability.contains(forbidden), + "capability.rs leaked {forbidden}" + ); + assert!(!host.contains(forbidden), "host.rs leaked {forbidden}"); + } +} diff --git a/tests/host_context_arch_tests.rs b/tests/host_context_arch_tests.rs new file mode 100644 index 00000000..d7a246c7 --- /dev/null +++ b/tests/host_context_arch_tests.rs @@ -0,0 +1,234 @@ +//! Architecture tests for the generic host-context boundary. +//! +//! These tests verify two properties that the host-context SDK commit +//! guarantees: +//! +//! 1. **Boundary hygiene** — `src/vm` (and, in particular, the boundary file +//! `src/vm/host_context.rs`) does not import builtin *domain* modules +//! (`sqlite`, `io`, `http`, `json`, ...) nor `rusqlite`. Standard SQLite / +//! IO / HTTP / SSE remain same-crate builtins; `src/vm` only owns the +//! generic boundary and must stay domain-agnostic. +//! 2. **Generic external registration** — an external host *extension* +//! registers typed, per-VM module state purely through the public +//! [`HostContext`] surface, without ever touching host-runtime internals +//! (which stay private) or a builtin domain type. + +use std::fs; +use std::path::{Path, PathBuf}; + +use vm::{HostExtension, Program, Vm}; + +/// The builtin *domain* modules that `src/vm` must not import. +const FORBIDDEN_DOMAIN_IMPORTS: &[&str] = &[ + "builtins::runtime::sqlite", + "builtins::runtime::io", + "builtins::runtime::http", + "builtins::runtime::json", + "builtins::runtime::typed", +]; + +/// `rusqlite` must never appear in `src/vm`. +const FORBIDDEN_RUSQLITE: &str = "rusqlite"; + +/// The generic VM must not depend on the builtin registration/adapter crate +/// path. Standard registration remains outside the recursive VM production +/// tree. +const FORBIDDEN_BUILTIN_CRATE_PATH: &str = "crate::builtins"; + +/// Concrete adapter state, policy, and dispatch symbols do not belong in the +/// generic VM. Keep these tokens explicit so a future adapter integration +/// cannot quietly reintroduce a domain branch under a different module. +const FORBIDDEN_ADAPTER_TOKENS: &[&str] = &[ + "BuiltinIo", + "BuiltinSqlite", + "poll_builtin_", + "poll_builtin_io", + "poll_builtin_io_op", + "poll_builtin_sqlite", + "poll_builtin_sqlite_op", + "cancel_builtin_", + "cancel_builtin_io", + "cancel_builtin_io_op", + "cancel_builtin_sqlite", + "cancel_builtin_sqlite_op", + "IoHostExt", + "SqliteHostExt", + "IoHostState", + "SqliteHostState", + "IoState", + "SqliteState", + "IoPolicy", + "SqlitePolicy", + "IoLimits", + "SqliteLimits", + "IoResource", + "SqliteResource", + "IoHandle", + "ConnectionSlot", + "IoOpDriver", + "SqliteOpDriver", + "IoOpShared", + "SqliteOpShared", + "io_policy", + "sqlite_policy", + "sqlite_state", + "current_policy", + "OperationOwner::Sqlite", + "ResourceTypeId::IO_FILE", + "ResourceTypeId::SQLITE_CONNECTION", + "cancel_operations_by_owner", + "close_resources_by_type", + "builtins::runtime::io", + "builtins::runtime::sqlite", +]; + +/// Adapter feature selection must remain in the adapter modules, never in the +/// recursive generic-VM production tree. +const FORBIDDEN_ADAPTER_FEATURES: &[&str] = &["async", "sqlite", "io", "http", "sse"]; + +fn vm_source_files() -> Vec { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let vm_dir = root.join("src").join("vm"); + let mut files = Vec::new(); + + fn visit(path: &Path, files: &mut Vec) { + let mut entries = fs::read_dir(path) + .unwrap_or_else(|error| panic!("read VM source directory {}: {error}", path.display())) + .collect::, _>>() + .unwrap_or_else(|error| panic!("read VM source entry {}: {error}", path.display())); + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + if path.is_dir() { + visit(&path, files); + } else if path.extension().is_some_and(|extension| extension == "rs") { + files.push(path); + } + } + } + + assert!(vm_dir.is_dir(), "expected VM source directory to exist"); + visit(&vm_dir, &mut files); + assert!( + !files.is_empty(), + "expected production Rust files under src/vm" + ); + files +} + +/// Removes `//` line comments and `/* ... */` block comments so the import +/// guards inspect actual code (imports / inline paths) rather than doc prose +/// that merely *discusses* the boundary rules. +fn strip_comments(source: &str) -> String { + let mut out = String::with_capacity(source.len()); + let bytes = source.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i += 2; + continue; + } + out.push(bytes[i] as char); + i += 1; + } + out +} + +#[test] +fn execution_scope_hides_raw_mutable_resource_table() { + let source = include_str!("../src/vm/execution_scope.rs"); + assert!( + source.contains("pub(crate) fn resources_mut"), + "raw ResourceTable access must stay inside the VM crate" + ); + assert!( + !source.contains("pub fn resources_mut"), + "public callers must use lifecycle-checked typed resource operations" + ); +} + +#[test] +fn vm_core_does_not_import_builtin_domain_modules() { + for file in vm_source_files() { + let source = fs::read_to_string(&file).expect("read vm source"); + let code = strip_comments(&source); + for forbidden in FORBIDDEN_DOMAIN_IMPORTS { + assert!( + !code.contains(forbidden), + "`src/vm` file `{}` must not import `{forbidden}`", + file.display() + ); + } + assert!( + !code.contains(FORBIDDEN_RUSQLITE), + "`src/vm` file `{}` must not reference rusqlite", + file.display() + ); + assert!( + !code.contains(FORBIDDEN_BUILTIN_CRATE_PATH), + "`src/vm` file `{}` must not reference the builtin registration crate", + file.display() + ); + for forbidden in FORBIDDEN_ADAPTER_TOKENS { + assert!( + !code.contains(forbidden), + "`src/vm` file `{}` must not reference concrete adapter token `{forbidden}`", + file.display() + ); + } + let compact = code + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + for feature in FORBIDDEN_ADAPTER_FEATURES { + let guard = format!("feature=\"{feature}\""); + assert!( + !compact.contains(&guard), + "`src/vm` file `{}` must not select adapter feature `{feature}`", + file.display() + ); + } + } +} + +/// Generic external extension: registers typed per-VM module state through the +/// public [`HostContext`] and the [`HostExtension`] lifecycle only. +#[derive(Debug)] +struct DemoPolicy { + max_items: u64, +} + +struct DemoExtension; + +impl HostExtension for DemoExtension { + fn install(&self, vm: &mut Vm) { + vm.host_context() + .set_module_state(DemoPolicy { max_items: 3 }); + } +} + +#[test] +fn external_extension_registers_module_state_through_public_surface() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + vm.install_extension(&DemoExtension) + .expect("extension should install"); + assert_eq!( + vm.host_context() + .module_state::() + .map(|policy| policy.max_items), + Some(3) + ); + // Module state is generic storage: it does not register as a resource. + assert_eq!(vm.host_context().resource_count(), 0); +} diff --git a/tests/host_resource_macro_runtime_tests.rs b/tests/host_resource_macro_runtime_tests.rs new file mode 100644 index 00000000..3dcda554 --- /dev/null +++ b/tests/host_resource_macro_runtime_tests.rs @@ -0,0 +1,229 @@ +extern crate vm as vm_sdk; + +pub mod vm { + pub use super::vm_sdk::*; +} + +use pd_host_function::pd_host_function; +use std::sync::atomic::{AtomicUsize, Ordering}; +use vm::resource::{ + CloseProgress, HostResource, ResourceCloseReason, ResourceMut, ResourceOwned, ResourceRef, +}; + +use vm::{Program, Value, Vm, VmError, VmResult}; + +use vm::resource; + +pub use vm::host_api; + +static KEYED_HANDLER_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[derive(Debug, PartialEq)] +struct Counter(i64); + +impl HostResource for Counter { + fn begin_close( + &mut self, + _reason: ResourceCloseReason, + ) -> vm::resource::ResourceResult { + Ok(CloseProgress::Ready) + } +} + +#[derive(Debug, PartialEq)] +struct KeyedCounter(i64); + +impl HostResource for KeyedCounter { + fn resource_type_key() -> Option { + Some(vm::ResourceTypeKey::new("macro.counter").expect("static key")) + } + + fn begin_close( + &mut self, + _reason: ResourceCloseReason, + ) -> vm::resource::ResourceResult { + Ok(CloseProgress::Ready) + } +} + +mod generated_parent { + use super::*; + + pub trait FromArg: Sized { + fn from_arg(value: &Value, label: &str) -> VmResult; + } + + impl FromArg for i64 { + fn from_arg(value: &Value, label: &str) -> VmResult { + match value { + Value::Int(value) => Ok(*value), + _ => Err(VmError::HostError(format!("expected {label}"))), + } + } + } + + pub fn arg(args: &[Value], index: usize, label: &str) -> VmResult { + args.get(index) + .ok_or_else(|| VmError::HostError(format!("missing {label}"))) + .and_then(|value| T::from_arg(value, label)) + } + + pub mod functions { + use super::*; + + /// Read a counter through a shared resource borrow. + #[pd_host_function(name = "test::borrow_counter")] + fn borrow_counter(counter: ResourceRef<'_, Counter>) -> VmResult { + Ok(counter.get().0) + } + + /// Increment a counter through a mutable resource borrow. + #[pd_host_function(name = "test::borrow_mut_counter")] + fn borrow_mut_counter(mut counter: ResourceMut<'_, Counter>) -> VmResult { + counter.get().0 += 1; + Ok(counter.get().0) + } + + /// Consume a counter through the public ResourceOwned wrapper. + #[pd_host_function(name = "test::take_counter")] + fn take_counter(counter: ResourceOwned) -> VmResult { + Ok(counter.into_inner().0) + } + + /// Consume a counter declared as a concrete TakeOwned parameter. + #[pd_host_function(name = "test::take_counter_concrete")] + fn take_counter_concrete( + #[pd_host_param(passing = "take_owned")] counter: Counter, + ) -> VmResult { + Ok(counter.0) + } + } + + pub mod keyed_functions { + use super::*; + + /// Reads a resource whose declaration carries the matching resource key. + #[pd_host_function(name = "test::keyed_borrow")] + fn keyed_borrow( + #[pd_host_resource(passing = "borrow", key = "macro.counter")] counter: ResourceRef< + '_, + KeyedCounter, + >, + ) -> VmResult { + KEYED_HANDLER_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(counter.get().0) + } + + /// Deliberately advertises a different resource key. + #[pd_host_function(name = "test::keyed_borrow_mismatch")] + fn keyed_borrow_mismatch( + #[pd_host_resource(passing = "borrow", key = "wrong.counter")] counter: ResourceRef< + '_, + KeyedCounter, + >, + ) -> VmResult { + KEYED_HANDLER_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(counter.get().0) + } + + /// Takes a resource whose declaration carries the matching resource key. + #[pd_host_function(name = "test::keyed_take")] + fn keyed_take( + #[pd_host_resource(passing = "take_owned", key = "macro.counter")] + counter: ResourceOwned, + ) -> VmResult { + KEYED_HANDLER_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(counter.into_inner().0) + } + + /// Deliberately advertises a different key on an owned resource path. + #[pd_host_function(name = "test::keyed_take_mismatch")] + fn keyed_take_mismatch( + #[pd_host_resource(passing = "take_owned", key = "wrong.counter")] + counter: ResourceOwned, + ) -> VmResult { + KEYED_HANDLER_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(counter.into_inner().0) + } + } +} + +#[test] +fn generated_public_resource_modes_execute_and_take_stales_the_token() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + let token = vm + .host_context() + .push_resource(Counter(10)) + .expect("resource registration"); + let args = [Value::Int(token.handle().raw() as i64)]; + + assert_eq!( + generated_parent::functions::borrow_counter(&mut vm, &args) + .expect("shared resource borrow"), + 10 + ); + + let mutable_args = args.clone(); + assert_eq!( + generated_parent::functions::borrow_mut_counter(&mut vm, &mutable_args) + .expect("mutable resource borrow"), + 11 + ); + + assert_eq!( + generated_parent::functions::take_counter(&mut vm, &args).expect("take-owned resource"), + 11 + ); + let stale = generated_parent::functions::take_counter(&mut vm, &args) + .expect_err("the token must be stale after take"); + assert!(stale.to_string().contains("already closed") || stale.to_string().contains("stale")); + + let replacement = vm + .host_context() + .push_resource(Counter(21)) + .expect("replacement resource registration"); + let replacement_args = [Value::Int(replacement.handle().raw() as i64)]; + assert_eq!( + generated_parent::functions::take_counter_concrete(&mut vm, &replacement_args) + .expect("concrete take-owned resource"), + 21 + ); +} + +#[test] +fn generated_resource_key_is_checked_before_borrow_and_take_logic() { + KEYED_HANDLER_CALLS.store(0, Ordering::SeqCst); + let mut vm = Vm::new(Program::new(Vec::new(), vec![vm::OpCode::Ret as u8])); + let token = vm + .host_context() + .push_resource(KeyedCounter(31)) + .expect("keyed resource registration"); + let args = [Value::Int(token.handle().raw() as i64)]; + + assert_eq!( + generated_parent::keyed_functions::keyed_borrow(&mut vm, &args) + .expect("matching borrow key"), + 31 + ); + assert_eq!(KEYED_HANDLER_CALLS.load(Ordering::SeqCst), 1); + + let mismatch = generated_parent::keyed_functions::keyed_borrow_mismatch(&mut vm, &args) + .expect_err("mismatched borrow key must fail before the handler"); + assert!(mismatch.to_string().contains("resource type key")); + assert_eq!(KEYED_HANDLER_CALLS.load(Ordering::SeqCst), 1); + assert_eq!(vm.host_context().resource_count(), 1); + + let take_mismatch = generated_parent::keyed_functions::keyed_take_mismatch(&mut vm, &args) + .expect_err("mismatched take key must fail before consuming"); + assert!(take_mismatch.to_string().contains("resource type key")); + assert_eq!(KEYED_HANDLER_CALLS.load(Ordering::SeqCst), 1); + assert_eq!(vm.host_context().resource_count(), 1); + + assert_eq!( + generated_parent::keyed_functions::keyed_take(&mut vm, &args).expect("matching take key"), + 31 + ); + assert_eq!(KEYED_HANDLER_CALLS.load(Ordering::SeqCst), 2); + assert_eq!(vm.host_context().resource_count(), 0); +} diff --git a/tests/host_resource_public_api_tests.rs b/tests/host_resource_public_api_tests.rs new file mode 100644 index 00000000..25004d43 --- /dev/null +++ b/tests/host_resource_public_api_tests.rs @@ -0,0 +1,114 @@ +use vm::resource::{ + CloseProgress, HostResource, ResourceCloseReason, ResourceErrorCode, ResourceOwned, +}; +use vm::{Program, Vm}; + +#[derive(Debug, PartialEq)] +struct Counter(i64); + +impl HostResource for Counter { + fn begin_close( + &mut self, + _reason: ResourceCloseReason, + ) -> vm::resource::ResourceResult { + Ok(CloseProgress::Ready) + } +} + +#[derive(Debug, PartialEq)] +struct Other; + +impl HostResource for Other {} + +#[test] +fn public_resource_owned_is_a_real_take_owned_value() { + let owned = ResourceOwned::new(Counter(7)); + assert_eq!(owned.as_ref(), &Counter(7)); + assert_eq!(owned.into_inner(), Counter(7)); +} + +#[test] +fn host_context_resource_modes_and_take_are_public_and_typed() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + let token = vm + .host_context() + .push_resource(Counter(7)) + .expect("resource registration"); + + { + let context = vm.host_context(); + assert_eq!( + context + .borrow_resource::(token.handle()) + .expect("shared borrow") + .0, + 7 + ); + } + { + let mut context = vm.host_context(); + context + .borrow_resource_mut::(token.handle()) + .expect("mutable borrow") + .0 = 9; + } + + let taken = vm + .host_context() + .take_resource::(token.handle()) + .expect("take-owned"); + assert_eq!(taken, Counter(9)); + assert_eq!(vm.host_context().resource_count(), 0); + + let stale = vm + .host_context() + .take_resource::(token.handle()) + .expect_err("a taken token cannot be taken twice"); + assert!(matches!( + stale.kind(), + vm::HostContextErrorKind::Scope(vm::execution_scope::ExecutionScopeError::Resource(_)) + )); + if let vm::HostContextErrorKind::Scope(vm::execution_scope::ExecutionScopeError::Resource( + resource_error, + )) = stale.kind() + { + assert_eq!( + resource_error.code(), + ResourceErrorCode::ResourceAlreadyClosed + ); + } +} + +#[test] +fn public_take_rejects_wrong_type_without_removing_resource() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + let token = vm + .host_context() + .push_resource(Counter(1)) + .expect("resource registration"); + + let error = vm + .host_context() + .take_resource::(token.handle()) + .expect_err("wrong type"); + if let vm::HostContextErrorKind::Scope(vm::execution_scope::ExecutionScopeError::Resource( + resource_error, + )) = error.kind() + { + assert_eq!( + resource_error.code(), + ResourceErrorCode::ResourceTypeMismatch + ); + } else { + panic!("expected resource error, got {error:?}"); + } + assert_eq!(vm.host_context().resource_count(), 1); + assert_eq!( + vm.host_context() + .take_resource::(token.handle()) + .expect("right type still succeeds"), + Counter(1) + ); +} diff --git a/tests/host_sdk_tests.rs b/tests/host_sdk_tests.rs new file mode 100644 index 00000000..cf167dd4 --- /dev/null +++ b/tests/host_sdk_tests.rs @@ -0,0 +1,646 @@ +//! Dedicated tests for the external host-extension SDK surface restored into +//! PR18: the host-API catalog model, the generic host-context boundary, and +//! the host-extension register/install lifecycle — all exercised through the +//! public crate API (the same surface an external host crate consumes). + +use std::sync::Arc; + +use vm::{ + BytecodeBuilder, CallOutcome, CallableKind, CallablePrototype, CallableTarget, HostApiBuilder, + HostApiCatalog, HostContextErrorKind, HostExtension, HostFunction, HostFunctionRegistry, + HostFunctionSchema, HostImport, HostImportSchema, HostParamPassing, HostTypeSchema, Program, + ResourceTypeKey, ResourceTypeSchema, Value, ValueType, Vm, VmError, VmResult, + catalog_import_schemas, operation, register_catalog_static_function, resource, +}; + +struct ReturnValue { + value: Value, +} + +impl HostFunction for ReturnValue { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(vm::CallReturn::one(self.value.clone()))) + } +} + +fn counter_key() -> ResourceTypeKey { + ResourceTypeKey::new("demo.counter").expect("static key") +} + +fn catalog() -> HostApiCatalog { + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(counter_key(), "A counter")); + builder.function(vm::HostFunctionSchema::with_return( + "demo::make", + vec![vm::HostParamSchema::value("seed", HostTypeSchema::Int)], + HostTypeSchema::Resource(counter_key()), + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::read", + vec![vm::HostParamSchema::with_passing( + "handle", + HostTypeSchema::Resource(counter_key()), + HostParamPassing::Borrow, + )], + HostTypeSchema::Int, + )); + builder.build().expect("catalog must build") +} + +#[test] +fn catalog_import_schemas_carries_fingerprint_and_passing() { + let catalog = catalog(); + let schemas = catalog_import_schemas(&catalog, "demo::read"); + assert_eq!(schemas.len(), 1); + assert_eq!(schemas[0].fingerprint, catalog.fingerprint()); + assert_eq!(schemas[0].params.len(), 1); + assert_eq!(schemas[0].params[0].passing, HostParamPassing::Borrow); +} + +#[test] +fn catalog_fingerprint_is_stable_and_semantic() { + let a = catalog(); + let mut b = HostApiBuilder::new(); + b.resource(ResourceTypeSchema::new(counter_key(), "Different docs")); + b.function(vm::HostFunctionSchema::with_return( + "demo::make", + vec![vm::HostParamSchema::value("seed", HostTypeSchema::Int)], + HostTypeSchema::Resource(counter_key()), + )); + b.function(vm::HostFunctionSchema::with_return( + "demo::read", + vec![vm::HostParamSchema::with_passing( + "handle", + HostTypeSchema::Resource(counter_key()), + HostParamPassing::Borrow, + )], + HostTypeSchema::Int, + )); + let b = b.build().expect("catalog must build"); + // Documentation is excluded from the fingerprint; semantic fields match. + assert_eq!(a.fingerprint(), b.fingerprint()); +} + +#[derive(Debug)] +struct Counter(u64); + +impl resource::HostResource for Counter { + fn resource_type_key() -> Option { + Some(counter_key()) + } + + fn begin_close( + &mut self, + _reason: resource::ResourceCloseReason, + ) -> resource::ResourceResult { + Ok(resource::CloseProgress::Ready) + } +} + +#[derive(Debug)] +struct TickingOp; + +impl operation::HostOperation for TickingOp { + fn poll( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + + fn cancel( + &mut self, + _reason: operation::OperationCancelReason, + ) -> operation::OperationResult<()> { + Ok(()) + } +} + +struct DemoExtension; + +impl HostExtension for DemoExtension { + fn register(&self, registry: &mut HostFunctionRegistry) -> VmResult<()> { + let catalog = catalog(); + let schemas = catalog_import_schemas(&catalog, "demo::make"); + assert!(!schemas.is_empty()); + registry.register_static("demo::make", 1, make_counter as vm::StaticHostFunction); + registry.register_static("demo::read", 1, read_counter as vm::StaticHostFunction); + Ok(()) + } + + fn install(&self, vm: &mut Vm) { + vm.host_context().set_module_state("installed"); + } +} + +fn make_counter(vm: &mut Vm, args: &[Value]) -> VmResult { + let seed = match args.first() { + Some(Value::Int(seed)) => *seed, + _ => return Err(VmError::TypeMismatch("int")), + }; + let token = vm + .host_context() + .push_resource(Counter(seed as u64)) + .map_err(|error| VmError::HostError(error.to_string()))?; + Ok(CallOutcome::Return(vm::return_one( + token.handle().raw() as i64 + ))) +} + +fn read_counter(vm: &mut Vm, args: &[Value]) -> VmResult { + let raw = match args.first() { + Some(Value::Int(raw)) => *raw, + _ => return Err(VmError::TypeMismatch("int")), + }; + let handle = resource::ResourceHandle::from_raw(raw as u64) + .map_err(|error| VmError::HostError(error.to_string()))?; + let value = vm + .host_context() + .borrow_resource::(handle) + .map_err(|error| VmError::HostError(error.to_string()))? + .0; + Ok(CallOutcome::Return(vm::return_one(value as i64))) +} + +#[test] +fn extension_register_and_install_are_transactional() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + vm.install_extension(&DemoExtension) + .expect("extension should install"); + assert_eq!(vm.host_context().module_state::<&str>(), Some(&"installed")); +} + +#[test] +fn host_context_inserts_resources_and_starts_operations() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + { + let token = vm + .host_context() + .push_resource(Counter(42)) + .expect("push counter"); + let value = vm + .host_context() + .borrow_resource::(token.handle()) + .expect("borrow counter") + .0; + assert_eq!(value, 42); + } + let id = vm + .host_context() + .start_operation(operation::OperationSpec::new(TickingOp)) + .expect("start operation"); + assert_eq!(vm.host_context().operation_count(), 1); + assert_eq!( + vm.host_context().operation_status(id).expect("status"), + operation::OperationStatus::Pending + ); +} + +#[test] +fn closing_scope_rejects_new_inserts_with_structured_error() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + // Drive the public execution scope into Closing (new inserts sealed). + vm.execution_scope() + .begin_close(resource::ResourceCloseReason::Requested) + .expect("begin close"); + let error = vm + .host_context() + .push_resource(Counter(1)) + .expect_err("closing scope must reject inserts"); + assert!(matches!( + error.kind(), + HostContextErrorKind::Scope(scope_error) + if matches!( + scope_error, + vm::execution_scope::ExecutionScopeError::ScopeClosing + ) + )); +} + +#[test] +fn external_operation_driver_cancels_on_scope_close() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let driver = TickingOpWithFlag(cancelled.clone()); + vm.host_context() + .start_operation(operation::OperationSpec::new(driver)) + .expect("start"); + drop(vm); + assert_eq!(cancelled.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +struct TickingOpWithFlag(std::sync::Arc); + +impl operation::HostOperation for TickingOpWithFlag { + fn poll( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + + fn cancel( + &mut self, + _reason: operation::OperationCancelReason, + ) -> operation::OperationResult<()> { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } +} + +fn overloaded_int(_vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(vm::return_one(11_i64))) +} + +fn overloaded_string(_vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(vm::return_one("string overload"))) +} + +#[test] +fn catalog_binding_keeps_same_name_overloads_by_full_schema() { + let mut builder = HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "demo::overloaded", + vec![vm::HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::overloaded", + vec![vm::HostParamSchema::value("value", HostTypeSchema::String)], + HostTypeSchema::String, + )); + let catalog = builder.build().expect("overload catalog"); + let schemas = catalog_import_schemas(&catalog, "demo::overloaded"); + assert_eq!(schemas.len(), 2); + + let mut registry = HostFunctionRegistry::empty(); + register_catalog_static_function( + &mut registry, + &catalog, + "demo::overloaded", + schemas[0].clone(), + overloaded_int, + ) + .expect("integer overload registration"); + register_catalog_static_function( + &mut registry, + &catalog, + "demo::overloaded", + schemas[1].clone(), + overloaded_string, + ) + .expect("string overload registration"); + + let mut bytecode = BytecodeBuilder::new(); + bytecode.ldc(0); + bytecode.call(0, 1); + bytecode.ldc(1); + bytecode.call(1, 1); + bytecode.ret(); + let imports = vec![ + HostImport { + name: "demo::overloaded".to_string(), + arity: 1, + return_type: ValueType::Int, + }, + HostImport { + name: "demo::overloaded".to_string(), + arity: 1, + return_type: ValueType::String, + }, + ]; + let program = Program::with_imports_and_debug( + vec![Value::Int(1), Value::string("x")], + bytecode.finish(), + imports, + None, + ) + .with_host_import_schemas(vec![schemas[0].clone(), schemas[1].clone()]) + .expect("schema metadata should align with imports"); + let encoded = vm::encode_program(&program).expect("overload VMBC should encode"); + let decoded = vm::decode_program(&encoded).expect("overload VMBC should decode"); + assert_eq!(decoded.host_import_schemas(), program.host_import_schemas()); + let mut vm = Vm::new(decoded); + registry + .bind_vm_cached(&mut vm) + .expect("full schemas should resolve both overloads"); + + assert_eq!( + vm.run().expect("overloads should execute"), + vm::VmStatus::Halted + ); + assert_eq!( + vm.stack(), + &[Value::Int(11), Value::string("string overload")] + ); + + let untyped_program = Program::with_imports_and_debug( + Vec::new(), + vec![vm::OpCode::Ret as u8], + vec![HostImport { + name: "demo::overloaded".to_string(), + arity: 1, + return_type: ValueType::Int, + }], + None, + ); + let mut untyped_vm = Vm::new(untyped_program); + let error = registry + .bind_vm_cached(&mut untyped_vm) + .expect_err("an overloaded import without full identity must be rejected"); + assert!(error.to_string().contains("full schema and fingerprint")); +} + +struct WrongDynamicReturn; + +impl vm::HostFunction for WrongDynamicReturn { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(vm::return_one(true))) + } +} + +#[test] +fn dynamic_host_return_is_checked_against_import_type() { + let mut bytecode = BytecodeBuilder::new(); + bytecode.call(0, 0); + bytecode.ret(); + let program = Program::with_imports_and_debug( + Vec::new(), + bytecode.finish(), + vec![HostImport { + name: "demo::wrong_return".to_string(), + arity: 0, + return_type: ValueType::Int, + }], + None, + ); + let mut vm = Vm::new(program); + let mut registry = HostFunctionRegistry::empty(); + registry.register("demo::wrong_return", 0, || Box::new(WrongDynamicReturn)); + registry + .bind_vm_cached(&mut vm) + .expect("dynamic host function should bind"); + assert!(matches!(vm.run(), Err(VmError::TypeMismatch("int")))); +} + +struct WrongArgsReturn; + +#[test] +fn callable_host_returns_validate_nested_authoritative_prototype_schema() { + let expected_callable = HostTypeSchema::Callable { + params: vec![HostTypeSchema::Int], + result: Box::new(HostTypeSchema::Bool), + }; + let expected_return = HostTypeSchema::Optional(Box::new(HostTypeSchema::Map(Box::new( + HostTypeSchema::Array(Box::new(expected_callable)), + )))); + let function = + HostFunctionSchema::with_return("demo::nested_callable", Vec::new(), expected_return); + let mut builder = HostApiBuilder::new(); + builder.function(function.clone()); + let catalog = builder.build().expect("callable catalog"); + let schema = HostImportSchema::from_function(&catalog, &function); + + let actual_callable_schema = vm::compiler::TypeSchema::Callable { + params: vec![vm::compiler::TypeSchema::String], + result: Box::new(vm::compiler::TypeSchema::Int), + }; + let callable = Value::Callable(Arc::new(vm::CallableValue { + prototype_id: 0, + kind: CallableKind::FunctionItem, + env: None, + })); + let returned = Value::map(vec![(Value::string("items"), Value::array(vec![callable]))]); + let mut bytecode = BytecodeBuilder::new(); + bytecode.call(0, 0); + bytecode.ret(); + let program = Program::with_imports_and_debug( + Vec::new(), + bytecode.finish(), + vec![HostImport { + name: schema.name.clone(), + arity: 0, + return_type: ValueType::Map, + }], + None, + ) + .with_host_import_schemas(vec![schema.clone()]) + .expect("schema metadata") + .with_callable_metadata( + Vec::new(), + vec![CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 1, + frame_local_count: 1, + parameter_slots: vec![0], + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: Some(actual_callable_schema), + }], + Vec::new(), + Vec::new(), + ); + let mut vm = Vm::new(program); + let mut registry = HostFunctionRegistry::empty(); + registry + .register_catalog(schema, move || { + Box::new(ReturnValue { + value: returned.clone(), + }) + }) + .expect("callable host registration"); + registry + .bind_vm_cached(&mut vm) + .expect("callable host binding"); + + let error = vm + .run() + .expect_err("nested callable schema mismatch must be rejected"); + assert!( + matches!(error, VmError::TypeMismatch("callable")), + "{error:?}" + ); +} + +impl vm::HostArgsFunction for WrongArgsReturn { + fn call(&mut self, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(vm::return_one(true))) + } +} + +struct WrongStackReturn; + +impl vm::HostStackFunction for WrongStackReturn { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(vm::return_one(true))) + } +} + +fn wrong_static_return(_vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(vm::return_one(true))) +} + +fn wrong_static_args_return(_args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(vm::return_one(true))) +} + +fn call_zero_arg_import(name: &str) -> Program { + let mut bytecode = BytecodeBuilder::new(); + bytecode.call(0, 0); + bytecode.ret(); + Program::with_imports_and_debug( + Vec::new(), + bytecode.finish(), + vec![HostImport { + name: name.to_string(), + arity: 0, + return_type: ValueType::Int, + }], + None, + ) +} + +#[test] +fn every_dynamic_dispatch_kind_checks_return_values() { + let mut dynamic_vm = Vm::new(call_zero_arg_import("demo::wrong_dynamic")); + let mut dynamic_registry = HostFunctionRegistry::empty(); + dynamic_registry.register("demo::wrong_dynamic", 0, || Box::new(WrongDynamicReturn)); + dynamic_registry + .bind_vm_cached(&mut dynamic_vm) + .expect("dynamic host function should bind"); + assert!(matches!( + dynamic_vm.run(), + Err(VmError::TypeMismatch("int")) + )); + + let mut args_vm = Vm::new(call_zero_arg_import("demo::wrong_args")); + let mut args_registry = HostFunctionRegistry::empty(); + args_registry.register_args("demo::wrong_args", 0, || Box::new(WrongArgsReturn)); + args_registry + .bind_vm_cached(&mut args_vm) + .expect("args host function should bind"); + assert!(matches!(args_vm.run(), Err(VmError::TypeMismatch("int")))); + + let mut stack_vm = Vm::new(call_zero_arg_import("demo::wrong_stack")); + let mut stack_registry = HostFunctionRegistry::empty(); + stack_registry.register_stack("demo::wrong_stack", 0, || Box::new(WrongStackReturn)); + stack_registry + .bind_vm_cached(&mut stack_vm) + .expect("stack host function should bind"); + assert!(matches!(stack_vm.run(), Err(VmError::TypeMismatch("int")))); + + let mut static_vm = Vm::new(call_zero_arg_import("demo::wrong_static")); + let mut static_registry = HostFunctionRegistry::empty(); + static_registry.register_static("demo::wrong_static", 0, wrong_static_return); + static_registry + .bind_vm_cached(&mut static_vm) + .expect("static host function should bind"); + assert!(matches!(static_vm.run(), Err(VmError::TypeMismatch("int")))); + + let mut static_args_vm = Vm::new(call_zero_arg_import("demo::wrong_static_args")); + let mut static_args_registry = HostFunctionRegistry::empty(); + static_args_registry.register_static_args( + "demo::wrong_static_args", + 0, + wrong_static_args_return, + ); + static_args_registry + .bind_vm_cached(&mut static_args_vm) + .expect("static args host function should bind"); + assert!(matches!( + static_args_vm.run(), + Err(VmError::TypeMismatch("int")) + )); +} + +struct ManyReturn; + +impl vm::HostFunction for ManyReturn { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(vm::CallReturn::many(vec![ + Value::Int(1), + Value::Int(2), + ]))) + } +} + +#[test] +fn host_return_cardinality_is_checked_before_values_reach_guest() { + let mut vm = Vm::new(call_zero_arg_import("demo::many_return")); + let mut registry = HostFunctionRegistry::empty(); + registry.register("demo::many_return", 0, || Box::new(ManyReturn)); + registry + .bind_vm_cached(&mut vm) + .expect("many-return host function should bind"); + let error = vm.run().expect_err("multiple values must be rejected"); + assert!(error.to_string().contains("cardinality"), "{error}"); +} + +#[test] +fn catalog_duplicate_registration_is_rejected_without_replacing_the_original() { + let mut builder = HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "demo::duplicate", + vec![vm::HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog = builder.build().expect("catalog"); + let schema = catalog_import_schemas(&catalog, "demo::duplicate") + .pop() + .expect("schema"); + let mut registry = HostFunctionRegistry::empty(); + register_catalog_static_function( + &mut registry, + &catalog, + "demo::duplicate", + schema.clone(), + overloaded_int, + ) + .expect("first registration"); + let duplicate = register_catalog_static_function( + &mut registry, + &catalog, + "demo::duplicate", + schema.clone(), + overloaded_string, + ) + .expect_err("duplicate full schema must fail"); + assert!(duplicate.to_string().contains("already registered")); + + let mut conflict_schema = schema.clone(); + conflict_schema.return_type = HostTypeSchema::Bool; + let conflict = registry + .register_catalog_static(conflict_schema, overloaded_string) + .expect_err("same dispatch shape with a different return is ambiguous"); + assert!(matches!( + conflict, + vm::RegistrySchemaError::DispatchConflict { .. } + )); + + let imports = vec![HostImport { + name: "demo::duplicate".to_string(), + arity: 1, + return_type: ValueType::Int, + }]; + let mut bytecode = BytecodeBuilder::new(); + bytecode.ldc(0); + bytecode.call(0, 1); + bytecode.ret(); + let program = + Program::with_imports_and_debug(vec![Value::Int(9)], bytecode.finish(), imports, None) + .with_host_import_schemas(vec![schema]) + .expect("metadata"); + let mut vm = Vm::new(program); + registry + .bind_vm_cached(&mut vm) + .expect("original registration remains bindable"); + vm.run().expect("original registration runs"); + assert_eq!(vm.stack(), &[Value::Int(11)]); +} diff --git a/tests/invocation_stream_tests.rs b/tests/invocation_stream_tests.rs new file mode 100644 index 00000000..35f2abcc --- /dev/null +++ b/tests/invocation_stream_tests.rs @@ -0,0 +1,1927 @@ +#![cfg(feature = "runtime")] + +//! Invocation item stream contract tests. +//! +//! An invocation behaves like `Stream>`: +//! zero or more `Event` items, then exactly one `Complete` item or one typed error, +//! then a fused end of stream. Input enters through ordinary callable arguments and +//! polling drives execution (backpressure). + +#[cfg(feature = "async")] +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Wake, Waker}; +use std::time::{Duration, Instant}; + +use vm::{ + HostAsyncBridge, HostAsyncOpTerminal, HostFunctionRegistry, InvocationError, InvocationItem, + InvocationPoll, Store, Value, Vm, VmError, compile_source, compile_source_for_repl_with_locals, + operation::{ + HostOperation, OperationCancelReason, OperationError, OperationErrorCode, OperationResult, + OperationSpec, + }, +}; + +/// Compiles a source, binds the default runtime host registry, and completes the +/// root frame so exported callables can be started. +fn compiled_vm(source: &str) -> Vm { + let program = compile_source(source) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default runtime host registry should bind"); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + vm +} + +/// Drives one exported `run` callable to the end of its invocation stream. +fn collect_items(vm: &mut Vm, args: Vec) -> Vec> { + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, args) + .expect("invocation should start"); + let mut items = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!( + Instant::now() < deadline, + "invocation drive loop must terminate" + ); + match invocation + .poll_next() + .expect("invocation poll should not fail") + { + InvocationPoll::Ready(Some(item)) => items.push(item), + InvocationPoll::Ready(None) => break, + InvocationPoll::Pending => std::thread::sleep(Duration::from_millis(1)), + } + } + items +} + +#[test] +fn invocation_input_arrives_as_ordinary_callable_arguments() { + let mut vm = compiled_vm( + r#" + pub fn run(input: map) -> map { + input; + } + "#, + ); + let input = Value::map(vec![(Value::string("kind"), Value::string("message"))]); + let items = collect_items(&mut vm, vec![input.clone()]); + assert_eq!(items.len(), 1, "expected exactly one stream item"); + assert!( + matches!(&items[0], Ok(InvocationItem::Complete(value)) if *value == input), + "the exact structured argument must be the callable input, got {:?}", + items + ); +} + +#[test] +fn invocation_without_events_yields_complete_then_fused_end() { + let mut vm = compiled_vm( + r#" + pub fn run() -> int { + 42; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable.clone(), vec![]) + .expect("invocation should start"); + + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); + assert!( + matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + ), + "the stream must stay fused after Complete" + ); + drop(invocation); + + // Once the first invocation has fused, a new invocation may start on the + // same VM. + let mut second = vm + .start_invocation(callable, vec![]) + .expect("a new invocation may start after fusion"); + assert!(matches!( + second.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) + )); + assert!(matches!( + second.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn dropping_an_unpolled_invocation_allows_a_second_invocation() { + let mut vm = compiled_vm( + r#" + pub fn run() -> int { + 42; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + { + // Dropping the handle retires even a CompletePending invocation whose + // terminal item was never observed. + let _invocation = vm + .start_invocation(callable.clone(), vec![]) + .expect("first invocation should start"); + } + let mut second = vm + .start_invocation(callable, vec![]) + .expect("dropping the first handle must release the vm immediately"); + assert!(matches!( + second.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) + )); +} + +#[test] +fn invocation_failures_are_typed_items_without_stack_or_string_inspection() { + let mut vm = compiled_vm( + r#" + pub fn run(input: int) -> int { + 100 / input; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![Value::Int(0)]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Vm(VmError::DivisionByZero)))) => {} + other => panic!("expected a typed division-by-zero item, got {other:?}"), + } + assert!( + matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + ), + "the stream must fuse after the error item" + ); +} + +/// Records one script-visible progress note per call. +struct ProgressNote(Arc>>); + +impl vm::HostArgsFunction for ProgressNote { + fn call(&mut self, args: &[Value]) -> vm::VmResult { + if let Some(value) = args.first() { + self.0 + .lock() + .expect("progress note lock should not be poisoned") + .push(value.clone()); + } + Ok(vm::CallOutcome::Return(vm::CallReturn::one( + args.first().cloned().unwrap_or(Value::Null), + ))) + } +} + +#[test] +fn invocation_emits_events_then_complete_in_order() { + let mut vm = compiled_vm( + r#" + use stream; + pub fn run() -> string { + stream::emit("first"); + stream::emit("second"); + "done"; + } + "#, + ); + let items = collect_items(&mut vm, vec![]); + assert_eq!( + items.len(), + 3, + "expected event, event, complete; got {items:?}" + ); + assert!( + matches!(&items[0], Ok(InvocationItem::Event(value)) if *value == Value::string("first")) + ); + assert!( + matches!(&items[1], Ok(InvocationItem::Event(value)) if *value == Value::string("second")) + ); + assert!( + matches!(&items[2], Ok(InvocationItem::Complete(value)) if *value == Value::string("done")) + ); +} + +#[test] +fn invocation_event_values_never_replace_the_callable_return_value() { + let mut vm = compiled_vm( + r#" + use stream; + pub fn run() -> int { + stream::emit("payload"); + 42; + } + "#, + ); + let items = collect_items(&mut vm, vec![]); + assert_eq!( + items.len(), + 2, + "expected event then complete; got {items:?}" + ); + assert!( + matches!(&items[0], Ok(InvocationItem::Event(value)) if *value == Value::string("payload")) + ); + assert!(matches!( + &items[1], + Ok(InvocationItem::Complete(Value::Int(42))) + )); +} + +#[test] +fn invocation_polling_pauses_execution_and_exposes_one_event_at_a_time() { + let program = compile_source( + r#" + use stream; + fn note_progress(value: string) -> string; + pub fn run() -> string { + stream::emit("a"); + note_progress("after-a"); + stream::emit("b"); + note_progress("after-b"); + "done"; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + let notes = Arc::new(Mutex::new(Vec::::new())); + vm.bind_args_function("note_progress", Box::new(ProgressNote(Arc::clone(¬es)))); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + // First poll: the script paused at the first emit; nothing after it ran. + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("a") + )); + assert!( + notes.lock().expect("notes lock").is_empty(), + "execution must not advance while polling is paused" + ); + + // Second poll: resume past emit(a), run note_progress("after-a"), pause at + // emit(b). Exactly one progress note may exist. + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("b") + )); + assert_eq!( + notes.lock().expect("notes lock").len(), + 1, + "exactly one progress note between polls" + ); + + // Third poll: resume past emit(b), run note_progress("after-b"), complete. + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(value)))) if value == Value::string("done") + )); + assert_eq!(notes.lock().expect("notes lock").len(), 2); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_cancellation_produces_one_typed_error_item_then_fused_end() { + let mut vm = compiled_vm( + r#" + use stream; + pub fn run() -> string { + stream::emit("before"); + while true { + 1; + } + "unreachable"; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("before") + )); + + invocation + .cancel(OperationCancelReason::Requested) + .expect("cancellation should be accepted"); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + OperationCancelReason::Requested, + )))) => {} + other => panic!("expected a typed cancellation item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_repeated_cancellation_preserves_the_first_reason() { + let mut vm = compiled_vm( + r#" + use stream; + pub fn run() -> string { + stream::emit("before"); + while true { + 1; + } + "unreachable"; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("before") + )); + + invocation + .cancel(OperationCancelReason::Requested) + .expect("first cancellation should be accepted"); + invocation + .cancel(OperationCancelReason::Deadline) + .expect("repeat cancellation should be idempotent"); + + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + OperationCancelReason::Requested, + )))) + )); +} + +#[test] +fn invocation_fuel_exhaustion_produces_one_typed_error_item() { + let mut vm = compiled_vm( + r#" + pub fn run() -> int { + while true { + 1; + } + 42; + } + "#, + ); + vm.set_fuel(8); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::OutOfFuel { + needed: _, + remaining: 0, + }))) => {} + other => panic!("expected a typed out-of-fuel item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_deadline_expiry_produces_one_typed_error_item() { + let mut vm = compiled_vm( + r#" + pub fn run() -> int { + 42; + } + "#, + ); + vm.set_epoch_deadline(0) + .expect("epoch deadline should be configured"); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::DeadlineReached { + current: 0, + deadline: 0, + }))) => {} + other => panic!("expected a typed deadline item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_host_failure_produces_one_typed_error_item() { + let program = compile_source( + r#" + fn fail_host() -> int; + pub fn run() -> int { + fail_host(); + 42; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_stack_function("fail_host", Box::new(FailingHost)); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Host { message }))) => { + assert_eq!(message, "boom"); + } + other => panic!("expected a typed host failure item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_event_bound_violations_are_typed_capability_errors() { + let mut vm = compiled_vm( + r#" + use stream; + pub fn run(input: string) -> int { + stream::emit(input); + 42; + } + "#, + ); + let oversized = "x".repeat(70 * 1024); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![Value::string(oversized)]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Capability(error)))) => { + assert_eq!(error.code(), vm::RuntimeErrorCode::EventPayloadTooLarge); + } + other => panic!("expected a typed capability error item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_result_is_isolated_from_completed_callback_results() { + let mut vm = compiled_vm( + r#" + pub fn queued_ok() -> int { + 11; + } + pub fn queued_fail(input: int) -> int { + 1 / input; + } + pub fn run() -> int { + 42; + } + "#, + ); + let queued_ok = vm + .resolve_exported_callable("queued_ok") + .expect("queued success callable should resolve"); + let queued_fail = vm + .resolve_exported_callable("queued_fail") + .expect("queued failure callable should resolve"); + vm.queue_callable(queued_ok, vec![]) + .expect("first callback should enter the queue"); + vm.queue_callable(queued_fail, vec![Value::Int(0)]) + .expect("second callback should enter the queue"); + + assert!(matches!( + vm.drain_callable_queue(), + Err(VmError::DivisionByZero) + )); + + let run = vm + .resolve_exported_callable("run") + .expect("run callable should resolve"); + let mut invocation = vm + .start_invocation(run, vec![]) + .expect("new invocation should start with prior callback results queued"); + assert!(matches!( + invocation + .poll_next() + .expect("invocation poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) + )); + drop(invocation); + assert_eq!( + vm.take_callable_result(), + Some(Value::Int(11)), + "the earlier callback result must remain available through the callback accessor" + ); + assert_eq!(vm.take_callable_result(), None); +} + +#[test] +fn start_invocation_rejects_a_foreign_vm_callable_before_mutating_state() { + let owner = compiled_vm("pub fn run() -> int { 7; }"); + let foreign = owner + .resolve_exported_callable("run") + .expect("owner callable should resolve"); + let mut target = compiled_vm("pub fn run() -> int { 42; }"); + let own = target + .resolve_exported_callable("run") + .expect("target callable should resolve"); + assert!(matches!( + (&foreign, &own), + (Value::Callable(foreign), Value::Callable(own)) + if foreign.prototype_id == own.prototype_id + )); + + assert!(matches!( + target.start_invocation(foreign, vec![]), + Err(VmError::InvalidCallable) + )); + assert!(target.execution_frames().is_empty()); + assert!(target.stack().is_empty()); + assert_eq!(target.queued_callable_count(), 0); + assert_eq!(target.take_callable_result(), None); + + let mut invocation = target + .start_invocation(own, vec![]) + .expect("the target VM callable should still be usable"); + assert!(matches!( + invocation + .poll_next() + .expect("invocation poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) + )); +} + +#[test] +fn start_invocation_rejects_a_foreign_callable_nested_in_an_array_before_mutating_state() { + let owner = compiled_vm("pub fn run(input: int) -> int { 7; }"); + let foreign = owner + .resolve_exported_callable("run") + .expect("owner callable should resolve"); + let mut target = compiled_vm("pub fn run(input: int) -> int { 42; }"); + let own = target + .resolve_exported_callable("run") + .expect("target callable should resolve"); + let before_ip = target.ip(); + let before_locals = target.locals().to_vec(); + let nested = Value::array(vec![Value::array(vec![foreign])]); + + assert!(matches!( + target.start_invocation(own, vec![nested]), + Err(VmError::InvalidCallable) + )); + assert_eq!(target.ip(), before_ip); + assert_eq!(target.locals(), before_locals.as_slice()); + assert!(target.execution_frames().is_empty()); + assert!(target.stack().is_empty()); + assert_eq!(target.call_depth(), 0); + assert_eq!(target.queued_callable_count(), 0); + assert_eq!(target.take_callable_result(), None); +} + +#[test] +fn start_invocation_rejects_foreign_callables_in_map_keys_and_values() { + let owner = compiled_vm("pub fn run(input: int) -> int { 7; }"); + let foreign = owner + .resolve_exported_callable("run") + .expect("owner callable should resolve"); + let mut target = compiled_vm("pub fn run(input: int) -> int { 42; }"); + let own = target + .resolve_exported_callable("run") + .expect("target callable should resolve"); + let before_ip = target.ip(); + let before_locals = target.locals().to_vec(); + let nested = Value::map(vec![ + (foreign.clone(), Value::Int(1)), + (Value::string("value"), foreign), + ]); + + assert!(matches!( + target.start_invocation(own, vec![nested]), + Err(VmError::InvalidCallable) + )); + assert_eq!(target.ip(), before_ip); + assert_eq!(target.locals(), before_locals.as_slice()); + assert!(target.execution_frames().is_empty()); + assert!(target.stack().is_empty()); + assert_eq!(target.call_depth(), 0); +} + +#[test] +fn start_invocation_rejects_a_callable_after_vm_reset() { + let mut target = compiled_vm("pub fn run(input: int) -> int { 42; }"); + let stale = target + .resolve_exported_callable("run") + .expect("target callable should resolve"); + target + .reset_for_reuse() + .expect("reset should complete without pending host work"); + let current = target + .resolve_exported_callable("run") + .expect("reset callable should resolve"); + let before_ip = target.ip(); + let before_locals = target.locals().to_vec(); + let before_frame_count = target.execution_frames().len(); + + assert!(matches!( + target.start_invocation(stale, vec![Value::Null]), + Err(VmError::InvalidCallable) + )); + assert_eq!(target.ip(), before_ip); + assert_eq!(target.locals(), before_locals.as_slice()); + assert_eq!(target.execution_frames().len(), before_frame_count); + assert!(target.stack().is_empty()); + assert_eq!(target.call_depth(), 0); + assert!(matches!(current, Value::Callable(_))); +} + +#[test] +fn start_invocation_rejects_a_foreign_captured_closure_before_mutating_state() { + let source = r#" + let seed = 7; + let closure = |value| value + seed; + "#; + let owner_compiled = compile_source_for_repl_with_locals(source, &[]) + .expect("owner closure source should compile"); + let mut owner = Vm::new( + owner_compiled + .compiled + .program + .with_local_count(owner_compiled.compiled.locals), + ); + assert_eq!( + owner.run().expect("owner root should halt"), + vm::VmStatus::Halted + ); + let foreign = owner + .locals() + .iter() + .find(|value| matches!(value, Value::Callable(callable) if callable.env.is_some())) + .cloned() + .expect("owner should expose a captured closure local"); + + let target_compiled = compile_source_for_repl_with_locals(source, &[]) + .expect("target closure source should compile"); + let mut target = Vm::new( + target_compiled + .compiled + .program + .with_local_count(target_compiled.compiled.locals), + ); + assert_eq!( + target.run().expect("target root should halt"), + vm::VmStatus::Halted + ); + assert!(matches!( + target.start_invocation(foreign, vec![Value::Int(1)]), + Err(VmError::InvalidCallable) + )); + assert!(target.execution_frames().is_empty()); + assert!(target.stack().is_empty()); + assert_eq!(target.take_callable_result(), None); +} + +/// Fails every host call with a plain embedding error. +struct FailingHost; + +impl vm::HostStackFunction for FailingHost { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + Err(vm::VmError::HostError("boom".to_string())) + } +} + +#[cfg(feature = "async")] +#[path = "support/async_test_bridge.rs"] +mod async_test_bridge; + +/// Waits asynchronously through the embedding-owned host bridge. +#[cfg(feature = "async")] +struct AsyncWaitHost; + +#[cfg(feature = "async")] +impl vm::HostStackFunction for AsyncWaitHost { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + vm.submit_host_future(Box::pin(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + Ok(vm::HostFutureOutput::returning(vm::CallReturn::one( + Value::Int(7), + ))) + })) + } +} + +#[cfg(feature = "async")] +struct RecordingWake(Arc); + +#[cfg(feature = "async")] +impl Wake for RecordingWake { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + + fn wake_by_ref(self: &Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +#[cfg(feature = "async")] +#[test] +fn invocation_context_poll_forwards_the_caller_waker_to_waiting_host_operation() { + let program = compile_source( + r#" + use stream; + fn wait_host() -> int; + pub fn run() -> string { + stream::emit("a"); + wait_host(); + stream::emit("b"); + "done"; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_stack_function("wait_host", Box::new(AsyncWaitHost)); + async_test_bridge::install(&mut vm); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + let mut noop_context = Context::from_waker(Waker::noop()); + assert!(matches!( + invocation + .poll_next_with_context(&mut noop_context) + .expect("event poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) + if value == Value::string("a") + )); + + let wake_count = Arc::new(AtomicUsize::new(0)); + let waker = Waker::from(Arc::new(RecordingWake(Arc::clone(&wake_count)))); + let mut cx = Context::from_waker(&waker); + assert!(matches!( + invocation + .poll_next_with_context(&mut cx) + .expect("waiting poll should succeed"), + InvocationPoll::Pending + )); + + let deadline = Instant::now() + Duration::from_secs(10); + while wake_count.load(Ordering::SeqCst) == 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(1)); + } + assert!( + wake_count.load(Ordering::SeqCst) > 0, + "the waiting host operation must wake the caller's waker" + ); + assert!(matches!( + invocation + .poll_next_with_context(&mut cx) + .expect("resumed poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) + if value == Value::string("b") + )); +} + +#[cfg(feature = "async")] +#[test] +fn invocation_waiting_host_operation_returns_pending_and_preserves_item_order() { + let program = compile_source( + r#" + use stream; + fn wait_host() -> int; + pub fn run() -> string { + stream::emit("a"); + wait_host(); + stream::emit("b"); + "done"; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_stack_function("wait_host", Box::new(AsyncWaitHost)); + async_test_bridge::install(&mut vm); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("a") + )); + + // The outstanding host operation maps to Pending; drive it and poll again. + let deadline = Instant::now() + Duration::from_secs(10); + let mut polled_pending = false; + let next = loop { + assert!( + Instant::now() < deadline, + "waiting invocation must resume through the host driver" + ); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Pending => { + polled_pending = true; + std::thread::sleep(Duration::from_millis(1)); + } + ready => break ready, + } + }; + assert!( + polled_pending, + "the waiting host op must surface as Pending" + ); + assert!(matches!( + next, + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("b") + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(value)))) if value == Value::string("done") + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[cfg(feature = "async")] +struct DelayedInvocationCancellationBridge { + runtime: tokio::runtime::Runtime, + futures: std::collections::HashMap, + acknowledgement: Arc, + cancellations: Arc>>, + cleanups: Arc>>, +} + +#[cfg(feature = "async")] +impl DelayedInvocationCancellationBridge { + fn new( + acknowledgement: Arc, + cancellations: Arc>>, + cleanups: Arc>>, + ) -> Self { + Self { + runtime: tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime should build"), + futures: std::collections::HashMap::new(), + acknowledgement, + cancellations, + cleanups, + } + } +} + +#[cfg(feature = "async")] +impl HostAsyncBridge for DelayedInvocationCancellationBridge { + fn submit_op(&mut self, op_id: vm::HostOpId, future: vm::HostFuture) -> vm::VmResult<()> { + if self.futures.insert(op_id, future).is_some() { + return Err(vm::VmError::HostError(format!( + "duplicate submitted host op {op_id}" + ))); + } + Ok(()) + } + + fn poll_op( + &mut self, + _op_id: vm::HostOpId, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Err(vm::VmError::HostError( + "unexpected external operation".to_string(), + ))) + } + + fn poll_submitted_op( + &mut self, + op_id: vm::HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + let poll = { + let Some(future) = self.futures.get_mut(&op_id) else { + return Poll::Ready(Err(vm::VmError::HostError(format!( + "unknown submitted host op {op_id}" + )))); + }; + let _guard = self.runtime.enter(); + future.as_mut().poll(cx) + }; + if poll.is_ready() { + self.futures.remove(&op_id); + } + poll + } + + fn request_cancel_op( + &mut self, + op_id: vm::HostOpId, + reason: OperationCancelReason, + ) -> vm::VmResult<()> { + self.cancellations + .lock() + .expect("cancellation lock") + .push((op_id, reason)); + Ok(()) + } + + fn poll_cancel_op( + &mut self, + _op_id: vm::HostOpId, + _cx: &mut Context<'_>, + ) -> Poll> { + if self.acknowledgement.load(Ordering::SeqCst) { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + fn cleanup_op( + &mut self, + op_id: vm::HostOpId, + terminal: HostAsyncOpTerminal, + ) -> vm::VmResult<()> { + self.futures.remove(&op_id); + self.cleanups + .lock() + .expect("cleanup lock") + .push((op_id, terminal)); + Ok(()) + } +} + +#[cfg(feature = "async")] +#[test] +fn invocation_cancellation_waits_for_bridge_acknowledgement() { + let program = compile_source( + r#" + use stream; + fn wait_host() -> int; + pub fn run() -> string { + stream::emit("before"); + wait_host(); + stream::emit("after"); + "done"; + } + "#, + ) + .expect("invocation source should compile") + .program; + let acknowledgement = Arc::new(AtomicBool::new(false)); + let cancellations = Arc::new(Mutex::new(Vec::new())); + let cleanups = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(program); + vm.bind_stack_function("wait_host", Box::new(AsyncWaitHost)); + vm.set_async_bridge(Box::new(DelayedInvocationCancellationBridge::new( + Arc::clone(&acknowledgement), + Arc::clone(&cancellations), + Arc::clone(&cleanups), + ))) + .expect("bridge installation should succeed"); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + assert!(matches!( + invocation.poll_next().expect("event poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) + if value == Value::string("before") + )); + assert!(matches!( + invocation.poll_next().expect("waiting poll should succeed"), + InvocationPoll::Pending + )); + + invocation + .cancel(OperationCancelReason::Deadline) + .expect("cancellation request should be accepted"); + assert!(matches!( + invocation.poll_next().expect("cancel poll should succeed"), + InvocationPoll::Pending + )); + assert_eq!( + *cancellations.lock().expect("cancellation lock"), + vec![(1, OperationCancelReason::Deadline)] + ); + assert!(cleanups.lock().expect("cleanup lock").is_empty()); + + acknowledgement.store(true, Ordering::SeqCst); + assert!(matches!( + invocation + .poll_next() + .expect("acknowledged cancel poll should succeed"), + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + OperationCancelReason::Deadline, + )))) + )); + assert!(matches!( + invocation.poll_next().expect("fused poll should succeed"), + InvocationPoll::Ready(None) + )); + assert_eq!( + *cleanups.lock().expect("cleanup lock"), + vec![(1, HostAsyncOpTerminal::Cancelled)] + ); +} + +#[cfg(feature = "async")] +#[test] +fn dropped_invocation_keeps_readiness_blocked_until_bridge_cleanup() { + let program = compile_source( + r#" + use stream; + fn wait_host() -> int; + pub fn run() -> string { + stream::emit("before"); + wait_host(); + stream::emit("after"); + "done"; + } + pub fn plain() -> int { + 41; + } + "#, + ) + .expect("invocation source should compile") + .program; + let acknowledgement = Arc::new(AtomicBool::new(false)); + let cancellations = Arc::new(Mutex::new(Vec::new())); + let cleanups = Arc::new(Mutex::new(Vec::new())); + let mut store = Store::from_vm(Vm::new(program)); + store + .vm_mut() + .bind_stack_function("wait_host", Box::new(AsyncWaitHost)); + store + .vm_mut() + .set_async_bridge(Box::new(DelayedInvocationCancellationBridge::new( + Arc::clone(&acknowledgement), + Arc::clone(&cancellations), + Arc::clone(&cleanups), + ))) + .expect("bridge installation should succeed"); + assert_eq!( + store.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let run_callable = store + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let plain_callable = store + .resolve_exported_callable("plain") + .expect("plain callable should resolve"); + let mut invocation = store + .vm_mut() + .start_invocation(run_callable, vec![]) + .expect("invocation should start"); + assert!(matches!( + invocation.poll_next().expect("event poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) + if value == Value::string("before") + )); + assert!(matches!( + invocation.poll_next().expect("waiting poll should succeed"), + InvocationPoll::Pending + )); + drop(invocation); + + assert!( + !store.is_reusable(), + "a dropped invocation must retain the bridge cancellation boundary" + ); + assert!(matches!( + store.vm_mut().run(), + Err(vm::VmError::HostError(message)) if message.contains("not quiescent") + )); + assert!(matches!( + store.vm_mut().resume(), + Err(vm::VmError::HostError(message)) if message.contains("not quiescent") + )); + assert!(matches!( + store.vm_mut().start_callable(plain_callable.clone(), &[]), + Err(vm::VmError::HostError(message)) if message.contains("not quiescent") + )); + assert!(matches!( + store.vm_mut().start_invocation(plain_callable.clone(), vec![]), + Err(vm::VmError::HostError(message)) if message.contains("not quiescent") + )); + assert_eq!( + *cancellations.lock().expect("cancellation lock"), + vec![(1, OperationCancelReason::Requested)] + ); + assert!(cleanups.lock().expect("cleanup lock").is_empty()); + + let mut context = Context::from_waker(Waker::noop()); + assert!(matches!( + store.vm_mut().poll_waiting_host_op(&mut context), + Poll::Pending + )); + acknowledgement.store(true, Ordering::SeqCst); + assert!(matches!( + store.vm_mut().poll_waiting_host_op(&mut context), + Poll::Ready(Ok(())) + )); + assert_eq!(store.vm().waiting_host_op_id(), None); + assert!(store.is_reusable(), "bridge cleanup should restore reuse"); + assert_eq!( + *cleanups.lock().expect("cleanup lock"), + vec![(1, HostAsyncOpTerminal::Cancelled)] + ); + + let mut second = store + .vm_mut() + .start_invocation(plain_callable, vec![]) + .expect("a new invocation may start after bridge cleanup"); + assert!(matches!( + second.poll_next().expect("plain invocation should poll"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(41))))) + )); + assert!(matches!( + second.poll_next().expect("plain invocation should fuse"), + InvocationPoll::Ready(None) + )); + drop(second); + assert_eq!(store.vm().waiting_host_op_id(), None); +} + +#[test] +fn invocation_cancellation_is_consumed_at_the_invocation_boundary() { + // Regression: after a cancelled invocation emits its typed error and + // fuses, the VM-level cancellation reason must not leak into a later + // invocation started on the same VM. + let mut vm = compiled_vm( + r#" + use stream; + pub fn run() -> string { + stream::emit("before"); + while true { + 1; + } + "unreachable"; + } + pub fn plain() -> int { + 42; + } + "#, + ); + let cancellable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(cancellable, vec![]) + .expect("invocation should start"); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("before") + )); + + invocation + .cancel(OperationCancelReason::Requested) + .expect("cancellation should be accepted"); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + OperationCancelReason::Requested, + )))) => {} + other => panic!("expected a typed cancellation item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); + drop(invocation); + + // A fresh invocation on the same VM must not inherit the old reason: it + // runs to completion instead of being cancelled on arrival. + let plain = vm + .resolve_exported_callable("plain") + .expect("exported plain callable should resolve"); + let mut second = vm + .start_invocation(plain, vec![]) + .expect("a new invocation may start after fusion"); + match second.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) => {} + other => panic!("the second invocation must complete normally, got {other:?}"), + } + assert!(matches!( + second.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_cancel_during_event_pending_discards_the_pending_event() { + // Cancellation is authoritative: a pending event that was placed but not + // yet delivered must be discarded (through the drop-contract path) and + // the stream must produce exactly one Cancelled item, then a fused end. + let program = compile_source( + r#" + use stream; + pub fn run() -> string { + stream::emit({"a": 1, "b": 2}); + while true { + 1; + } + "unreachable"; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.set_drop_contract_events_enabled(true); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default runtime host registry should bind"); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let drops_before_cancel = vm.drop_contract_event_count(); + // `start_callable` runs to the first `stream::emit` yield, so the + // invocation is already in EventPending with the map payload. + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + invocation + .cancel(OperationCancelReason::Requested) + .expect("cancellation should be accepted"); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + OperationCancelReason::Requested, + )))) => {} + other => panic!("cancellation must supersede the pending event, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); + drop(invocation); + + // The discarded event payload (map plus its two key/value pairs) must be + // dropped through the VM drop-contract path, not leaked. + assert!( + vm.drop_contract_event_count() >= drops_before_cancel + 5, + "the discarded pending event payload must be dropped through the drop contract path" + ); +} + +#[test] +fn invocation_cancel_during_complete_pending_discards_the_pending_complete() { + // Cancellation is authoritative over a not-yet-delivered Complete item: + // the callable result is discarded and the stream produces exactly one + // Cancelled item, then a fused end. + let mut vm = compiled_vm( + r#" + pub fn run() -> map { + {"a": 1, "b": 2}; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + // The callable completes during `start_callable`, so the invocation is + // already in CompletePending with the return map. + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + invocation + .cancel(OperationCancelReason::Deadline) + .expect("cancellation should be accepted"); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + OperationCancelReason::Deadline, + )))) => {} + other => panic!("cancellation must supersede the pending complete, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +/// Fails asynchronously on the first poll of its submitted host operation. +#[cfg(feature = "async")] +struct AsyncFailHost; + +#[cfg(feature = "async")] +impl vm::HostStackFunction for AsyncFailHost { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + vm.submit_host_future(Box::pin(async move { + Err(vm::VmError::HostError("bridge future failed".to_string())) + })) + } +} + +#[cfg(feature = "async")] +#[test] +fn invocation_host_op_first_poll_failure_keeps_typed_host_error() { + // Regression: the waiting host op is polled once with a noop waker; if the + // first poll fails and clears the waiting state, the typed mapping must + // still surface (here a `Host` error) on the invocation stream. + let program = compile_source( + r#" + fn fail_host() -> int; + pub fn run() -> int { + fail_host(); + 42; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_stack_function("fail_host", Box::new(AsyncFailHost)); + async_test_bridge::install(&mut vm); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Host { message }))) => { + assert_eq!(message, "bridge future failed"); + } + other => panic!("expected a typed host error item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[cfg(feature = "async")] +#[test] +fn invocation_cancellation_while_waiting_produces_one_typed_error_item() { + let program = compile_source( + r#" + use stream; + fn wait_host() -> int; + pub fn run() -> string { + stream::emit("a"); + wait_host(); + "unreachable"; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_stack_function("wait_host", Box::new(AsyncWaitHost)); + async_test_bridge::install(&mut vm); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("a") + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Pending + )); + + invocation + .cancel(OperationCancelReason::Deadline) + .expect("cancellation should be accepted"); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + OperationCancelReason::Deadline, + )))) => {} + other => panic!("expected a typed cancellation item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[derive(Debug)] +struct PendingCloseResource { + ready: Arc, + dropped: Arc, +} + +impl vm::resource::close::HostResource for PendingCloseResource { + fn begin_close( + &mut self, + _reason: vm::ResourceCloseReason, + ) -> vm::resource::error::ResourceResult { + Ok(vm::resource::close::CloseProgress::Pending) + } + + fn poll_close( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + if self.ready.load(Ordering::SeqCst) { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } +} + +#[derive(Debug)] +struct FailingCloseResource; + +impl vm::resource::close::HostResource for FailingCloseResource { + fn begin_close( + &mut self, + _reason: vm::ResourceCloseReason, + ) -> vm::resource::error::ResourceResult { + Ok(vm::resource::close::CloseProgress::Pending) + } + + fn poll_close( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Err(vm::resource::error::ResourceError::new( + vm::resource::error::ResourceErrorCode::ResourceCleanupFailed, + "test", + "scope cleanup failed", + ))) + } +} + +struct FailingCancelOperation; + +impl HostOperation for FailingCancelOperation { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + + fn cancel(&mut self, _reason: OperationCancelReason) -> OperationResult<()> { + Err(OperationError::new( + OperationErrorCode::OperationDriverFailed, + "test", + "operation cancellation failed", + )) + } + + fn is_quiescent(&self) -> bool { + true + } +} + +impl Drop for PendingCloseResource { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } +} + +#[test] +fn reset_does_not_reuse_vm_scope_before_generic_quiescence() { + let ready = Arc::new(AtomicBool::new(false)); + let dropped = Arc::new(AtomicBool::new(false)); + let mut vm = Vm::new(vm::Program::new(Vec::new(), vec![vm::OpCode::Ret as u8])); + vm.host_context() + .push_resource(PendingCloseResource { + ready: Arc::clone(&ready), + dropped: Arc::clone(&dropped), + }) + .expect("resource should enter the active execution scope"); + + let _ = vm.reset_for_reuse(); + assert!( + vm.scope_reset_pending(), + "reset must retain a closing scope" + ); + assert!( + !dropped.load(Ordering::SeqCst), + "pending resources must stay owned" + ); + assert!(matches!(vm.run(), Err(vm::VmError::ExecutionScope(_)))); + + ready.store(true, Ordering::SeqCst); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(!vm.scope_reset_pending()); + assert!( + dropped.load(Ordering::SeqCst), + "close must release after quiescence" + ); + assert_eq!(vm.host_context().resource_count(), 0); + assert_eq!( + vm.run().expect("reused VM should run"), + vm::VmStatus::Halted + ); +} + +#[test] +fn reset_surfaces_first_operation_error_and_does_not_install_a_fresh_scope() { + let mut vm = Vm::new(vm::Program::new(Vec::new(), vec![vm::OpCode::Ret as u8])); + vm.execution_scope() + .start_operation(OperationSpec::new(FailingCancelOperation)) + .expect("operation should enter the active execution scope"); + vm.execution_scope() + .push_resource(FailingCloseResource) + .expect("resource should enter the active execution scope"); + + let reset_error = vm + .reset_for_reuse() + .expect_err("reset must report cleanup failures"); + let VmError::ExecutionScope(vm::execution_scope::ExecutionScopeError::Close( + vm::execution_scope::ScopeCloseOutcome::SuccessWithErrors(failure), + )) = reset_error + else { + panic!("reset must preserve the terminal scope close outcome"); + }; + assert_eq!(failure.failed, 2); + assert!(matches!( + failure.first, + vm::execution_scope::ScopeCloseError::Operation(error) + if error.code() == OperationErrorCode::OperationDriverFailed + )); + + let mut cx = Context::from_waker(Waker::noop()); + match vm.poll_reset_for_reuse(&mut cx) { + Poll::Ready(Err(VmError::ExecutionScope( + vm::execution_scope::ExecutionScopeError::Close( + vm::execution_scope::ScopeCloseOutcome::SuccessWithErrors(_), + ), + ))) => {} + other => panic!("cleanup failures must remain observable, got {other:?}"), + } + assert!( + vm.scope_reset_pending(), + "a failed close must not publish a reusable replacement scope" + ); +} + +#[test] +fn reset_clears_queued_callable_state_before_store_reuse() { + let program = compile_source("pub fn queued() -> int { 7 }") + .expect("queued callable program should compile"); + let mut store = Store::from_vm(Vm::new(program.program)); + let callback = store + .script_callback_by_name::<(), i64>("queued") + .expect("queued callable should be exported"); + let prepared = callback.prepare(()).expect("callback should prepare"); + store + .enqueue_callback(prepared) + .expect("callback should enter the VM queue"); + assert!( + !store.is_reusable(), + "存在 queued callback 时 store 不应进入复用池" + ); + + store + .reset_for_reuse() + .expect("reset should clear queued callable state"); + + assert!( + !callback.is_subscribed(), + "reset must invalidate queued callback aliases" + ); + assert_eq!( + store.run().expect("reset VM should reach root halt"), + vm::VmStatus::Halted + ); + assert!(store.is_reusable(), "reset 完成且队列清空后 store 应可复用"); + assert!( + store + .drain_callbacks() + .expect("queue drain should succeed") + .is_empty(), + "reset must discard queued callable state" + ); +} + +#[test] +fn store_is_not_reusable_while_completed_callback_results_wait() { + let program = + compile_source("pub fn ok() -> int { 7 } pub fn fail(input: int) -> int { 1 / input }") + .expect("callback result program should compile"); + let mut store = Store::from_vm(Vm::new(program.program)); + store.run().expect("root frame should complete"); + + let ok = store + .script_callback_by_name::<(), i64>("ok") + .expect("ok callback should be available"); + let fail = store + .script_callback_by_name::<(i64,), i64>("fail") + .expect("failing callback should be available"); + store + .enqueue_callback(ok.prepare(()).expect("ok callback should prepare")) + .expect("ok callback should enter the queue"); + store + .enqueue_callback( + fail.prepare((0_i64,)) + .expect("failing callback should prepare"), + ) + .expect("failing callback should enter the queue"); + + assert!( + store.drain_callbacks().is_err(), + "第二个 callback 失败时 drain 应返回错误" + ); + assert!( + !store.is_reusable(), + "存在待领取 callback result 时 store 不应进入复用池" + ); + assert_eq!( + store + .take_callback_result::() + .expect("completed callback result should be readable"), + Some(7) + ); +} + +#[test] +fn store_does_not_publish_callbacks_while_scope_reset_is_pending() { + let program = compile_source("pub fn queued() -> int { 7 }") + .expect("queued callable program should compile"); + let mut store = Store::from_vm(Vm::new(program.program)); + let ready = Arc::new(AtomicBool::new(false)); + store + .vm_mut() + .host_context() + .push_resource(PendingCloseResource { + ready: Arc::clone(&ready), + dropped: Arc::new(AtomicBool::new(false)), + }) + .expect("resource should enter the active execution scope"); + + assert!(store.reset_for_reuse().is_ok()); + assert!(store.vm().scope_reset_pending()); + assert!(!store.is_reusable()); + assert!( + store.script_callback_by_name::<(), i64>("queued").is_err(), + "a store must not install a new callback registry before scope quiescence" + ); + + ready.store(true, Ordering::SeqCst); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + store.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(store.is_reusable()); + let callback = store + .script_callback_by_name::<(), i64>("queued") + .expect("callbacks should be available after scope quiescence"); + assert!( + callback.is_subscribed(), + "完成 reset 后创建的 callback 应处于有效状态" + ); + for _ in 0..2 { + assert!(matches!( + store.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + assert!( + callback.is_subscribed(), + "重复 poll 不得替换完成 reset 后的 callback registry" + ); + } +} + +#[test] +fn repeated_completed_reset_poll_preserves_new_callback_registry() { + let program = compile_source("pub fn queued() -> int { 7 }") + .expect("queued callable program should compile"); + let mut store = Store::from_vm(Vm::new(program.program)); + + store.reset_for_reuse().expect("同步 reset 应成功完成"); + let callback = store + .script_callback_by_name::<(), i64>("queued") + .expect("完成 reset 后应能创建 callback"); + let mut cx = Context::from_waker(Waker::noop()); + for _ in 0..3 { + assert!(matches!( + store.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + assert!( + callback.is_subscribed(), + "完成 reset 的重复 poll 不得使 callback 失效" + ); + } + + store.run().expect("reset 后 root frame 应完成"); + assert_eq!( + callback + .call(&mut store, ()) + .expect("callback 应在重复 poll 后仍可调用"), + 7 + ); +} + +struct YieldingHost; + +impl vm::HostStackFunction for YieldingHost { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + Ok(vm::CallOutcome::Yield) + } +} + +struct ObservingHost { + reusable: Arc, +} + +impl vm::HostStackFunction for ObservingHost { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + self.reusable.store(vm.is_reusable(), Ordering::SeqCst); + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(1)))) + } +} + +#[test] +fn direct_host_execution_is_not_reusable_while_callback_runs() { + let program = compile_source("fn pause() -> int; pause();") + .expect("host callable program should compile") + .program; + let reusable = Arc::new(AtomicBool::new(false)); + let mut vm = Vm::new(program); + vm.bind_stack_function( + "pause", + Box::new(ObservingHost { + reusable: Arc::clone(&reusable), + }), + ); + assert_eq!( + vm.run().expect("host callable should complete"), + vm::VmStatus::Halted + ); + assert!( + !reusable.load(Ordering::SeqCst), + "host callback 执行期间 VM 不应可复用" + ); +} + +#[test] +fn active_script_frames_make_vm_non_reusable() { + let program = compile_source("fn pause() -> int; pub fn run() -> int { pause(); 42 }") + .expect("yielding callable program should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_stack_function("pause", Box::new(YieldingHost)); + assert_eq!( + vm.run().expect("root frame should complete"), + vm::VmStatus::Halted + ); + let callable = vm + .resolve_exported_callable("run") + .expect("run callable should resolve"); + + assert_eq!( + vm.start_callable(callable, &[]) + .expect("script call should yield"), + vm::VmStatus::Yielded + ); + assert!(!vm.is_reusable(), "存在活动 script frame 时 VM 不应可复用"); +} + +#[cfg(feature = "async")] +#[test] +fn submitted_bridge_operations_make_vm_non_reusable() { + let program = compile_source("pub fn noop() -> int { 1 }") + .expect("bridge state program should compile") + .program; + let mut vm = Vm::new(program); + async_test_bridge::install(&mut vm); + vm.submit_host_future(Box::pin(async { + Ok(vm::HostFutureOutput::returning(vm::CallReturn::one( + Value::Int(1), + ))) + })) + .expect("future should enter the host bridge"); + + assert!( + !vm.is_reusable(), + "存在 submitted bridge operation 时 VM 不应进入复用池" + ); +} diff --git a/tests/jit/jit_tests.rs b/tests/jit/jit_tests.rs index 2cca3dfb..daf69df2 100644 --- a/tests/jit/jit_tests.rs +++ b/tests/jit/jit_tests.rs @@ -1050,7 +1050,7 @@ fn aot_survives_reset_for_reuse() { let first_execs = vm.aot_exec_count(); assert!(first_execs > 0, "first run should execute aot"); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert!(vm.has_aot_program(), "reset should preserve aot program"); let second = vm.run().expect("second aot run should halt"); @@ -1219,7 +1219,7 @@ fn trace_jit_native_path_honors_fuel_metering() { "expected warmup to compile and execute native traces, dump:\n{}", vm.dump_jit_info() ); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); } vm.set_fuel_check_interval(1) .expect("fuel interval update should succeed"); @@ -1352,7 +1352,7 @@ fn changing_fuel_interval_recompiles_native_trace_variant() { let bytes_first = first_native_code_bytes(&dump_first).expect("first run should produce native code bytes"); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); vm.set_fuel_check_interval(8) .expect("fuel interval update should succeed"); vm.set_fuel(1_000_000); @@ -1443,7 +1443,7 @@ fn native_trace_epoch_zero_deadline_auto_rearms_without_manual_reconfiguration() vm.dump_jit_info() ); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); vm.set_epoch_check_interval(1) .expect("epoch interval update should succeed"); vm.set_epoch_deadline(0) @@ -1570,7 +1570,7 @@ fn changing_epoch_interval_recompiles_native_trace_variant() { let bytes_first = first_native_code_bytes(&dump_first).expect("first run should produce native code bytes"); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); vm.set_epoch_check_interval(8) .expect("epoch interval update should succeed"); vm.set_epoch_deadline(1) @@ -2277,7 +2277,7 @@ fn trace_jit_sparse_heap_exit_transfers_ownership_across_reuse() { assert_native_ssa_call_boundary_trace(&vm, &snapshot, "sparse heap exit"); if run == 0 { - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(std::sync::Arc::strong_count(&old), counts.0); assert_eq!(std::sync::Arc::strong_count(&replacement), counts.1 - 3); } @@ -2605,7 +2605,7 @@ fn trace_jit_direct_side_link_bypasses_rust_dispatch() { ); vm.clear_jit_native_bridge_stats(); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(16_384)]); assert!( @@ -2744,7 +2744,7 @@ fn trace_jit_side_link_generation_prevents_stale_entry_reuse() { }); assert_eq!(vm.jit_native_active_direct_link_slot_count(), 0); assert_eq!(vm.jit_native_direct_link_count(), 0); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(16_384)]); assert!(vm.jit_native_direct_link_count() > 4_000); @@ -2782,7 +2782,7 @@ fn trace_jit_side_link_respects_callable_frame_and_interrupt_boundaries() { assert_eq!(vm.stack(), &[Value::Int(16_384)]); assert!(vm.jit_native_direct_link_count() > 4_000); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); vm.set_fuel(8); let mut fuel_yields = 0_u64; loop { @@ -2845,7 +2845,7 @@ fn trace_jit_region_links_hot_same_frame_side_exit() { assert!(first_region_entries > 0, "{}", vm.dump_jit_info()); assert!(first_internal_edges > 0, "{}", vm.dump_jit_info()); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().expect("second region run"), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(1_024)]); @@ -2906,7 +2906,7 @@ fn trace_jit_region_cycle_propagates_disjoint_dirty_locals() { ); assert_eq!(vm.jit_native_region_count(), 1, "{}", vm.dump_jit_info()); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!( vm.run().expect("second disjoint dirty region run"), VmStatus::Halted @@ -2999,7 +2999,7 @@ fn trace_jit_region_preserves_owned_value_drop_contract() { vm.set_drop_contract_events_enabled(true); assert_eq!(vm.jit_native_region_count(), 0); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(Arc::strong_count(&output), 1); drop(vm); assert_eq!(Arc::strong_count(&output), 1); @@ -3041,7 +3041,7 @@ fn trace_jit_region_progress_prevents_callable_frame_backoff() { let first_execs = vm.jit_native_exec_count(); let first_edges = vm.jit_native_internal_region_edge_count(); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(1_025)]); assert!( @@ -3104,7 +3104,7 @@ fn trace_jit_inherited_direct_progress_prevents_callable_frame_backoff() { let first_direct_links = vm.jit_native_direct_link_count(); vm.clear_jit_native_bridge_stats(); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(8_224)]); assert!( @@ -3157,7 +3157,7 @@ fn trace_jit_region_respects_fuel_and_epoch_interrupts() { assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.jit_native_region_count(), 1, "{}", vm.dump_jit_info()); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); vm.set_fuel(8); let mut fuel_yields = 0_u64; loop { @@ -3177,13 +3177,13 @@ fn trace_jit_region_respects_fuel_and_epoch_interrupts() { assert_eq!(vm.jit_native_region_count(), 1, "{}", vm.dump_jit_info()); vm.clear_fuel(); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); vm.set_epoch_check_interval(1).unwrap(); vm.set_epoch_deadline(1_000_000).unwrap(); assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.jit_native_region_count(), 1, "{}", vm.dump_jit_info()); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); vm.set_epoch_deadline(0).unwrap(); assert_eq!(vm.run().unwrap(), VmStatus::Yielded); assert_eq!(vm.last_yield_reason(), Some(VmYieldReason::Epoch)); @@ -3272,7 +3272,7 @@ fn trace_jit_region_republishes_after_native_settings_change() { assert_eq!(vm.jit_native_region_count(), 1, "{}", vm.dump_jit_info()); let first_edges = vm.jit_native_internal_region_edge_count(); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); vm.set_fuel_check_interval(1).unwrap(); vm.set_fuel(1_000_000); assert_eq!(vm.run().unwrap(), VmStatus::Halted); @@ -3314,7 +3314,7 @@ fn trace_jit_region_invalidation_releases_owner_and_can_republish() { vm.set_drop_contract_events_enabled(true); assert_eq!(vm.jit_native_region_count(), 0); vm.set_drop_contract_events_enabled(false); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(1_024)]); assert_eq!(vm.jit_native_region_count(), 1, "{}", vm.dump_jit_info()); @@ -5857,7 +5857,7 @@ fn trace_jit_reuses_nested_frame_trace_after_reset() { let first_native_exec_count = vm.jit_native_exec_count(); assert!(first_native_exec_count > 0, "{}", vm.dump_jit_info()); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().expect("second run"), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(145)]); assert!(vm.jit_native_exec_count() > first_native_exec_count); @@ -6312,7 +6312,7 @@ fn trace_jit_region_cycles_without_external_handoffs() { assert!(first_region_entries > 0, "{}", vm.dump_jit_info()); assert!(first_internal_edges > 0, "{}", vm.dump_jit_info()); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!( vm.run().expect("linked callable loop should run again"), VmStatus::Halted @@ -6362,7 +6362,7 @@ fn trace_jit_direct_links_cross_frame_call_and_return_edges() { let first_direct = vm.jit_native_direct_link_count(); let first_handoffs = vm.jit_native_link_handoff_count(); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(12_288)]); let direct_delta = vm.jit_native_direct_link_count() - first_direct; @@ -6402,7 +6402,7 @@ fn trace_jit_missing_dynamic_return_target_never_uses_stale_static_continuation( assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(2_917)]); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(2_917)]); } @@ -6434,13 +6434,13 @@ fn trace_jit_direct_link_slots_clear_and_republish_after_mode_toggle() { assert!(vm.jit_native_direct_link_count() > 500); vm.set_jit_native_direct_links_enabled(false); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(2_048)]); assert_eq!(vm.jit_native_direct_link_count(), 0); vm.set_jit_native_direct_links_enabled(true); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(2_048)]); assert!( @@ -6685,7 +6685,7 @@ fn trace_jit_invalidates_native_inline_after_callable_local_replacement() { assert_eq!(vm.stack(), &[Value::Int(100)]); assert!(vm.jit_native_trace_count() > 0, "{}", vm.dump_jit_info()); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); vm.set_local( u8::try_from(replaced_slot).expect("root callable slot should fit u8"), Value::Callable(Arc::new(vm::CallableValue { @@ -7242,7 +7242,7 @@ fn trace_jit_call_site_profiles_clear_on_vm_reuse() { assert_eq!(vm.run().expect("first profile run"), VmStatus::Halted); assert_eq!(vm.jit_snapshot().metrics.script_call_observations, 3); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(vm.jit_snapshot().metrics.script_call_observations, 0); assert!(vm.jit_call_site_profiles().is_empty()); diff --git a/tests/jit/perf_tests.rs b/tests/jit/perf_tests.rs index c958aa3e..769c16d6 100644 --- a/tests/jit/perf_tests.rs +++ b/tests/jit/perf_tests.rs @@ -1509,7 +1509,7 @@ fn build_map_builtin_perf_source(entries: &[(&str, i64)], outer_loops: i64) -> S fn warm_reusable_vm_once(vm: &mut Vm, expected_stack: &[Value]) -> std::time::Duration { let elapsed = run_vm_once(vm, expected_stack); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); elapsed } @@ -1552,7 +1552,7 @@ fn sample_reused_vm_latencies( for _ in 0..trials { let elapsed = run_vm_once(vm, expected_stack); samples.push(elapsed); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); } samples } diff --git a/tests/support/async_test_bridge.rs b/tests/support/async_test_bridge.rs new file mode 100644 index 00000000..afe868fd --- /dev/null +++ b/tests/support/async_test_bridge.rs @@ -0,0 +1,85 @@ +use std::collections::HashMap; +use std::task::{Context, Poll}; + +use vm::{ + CallReturn, HostAsyncBridge, HostFuture, HostFutureOutput, HostOpId, Vm, VmError, VmResult, +}; + +struct TokioTestBridge { + runtime: tokio::runtime::Runtime, + futures: HashMap, +} + +impl TokioTestBridge { + fn new() -> Self { + Self { + runtime: tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("test runtime should build"), + futures: HashMap::new(), + } + } +} + +impl HostAsyncBridge for TokioTestBridge { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + if self.futures.insert(op_id, future).is_some() { + return Err(VmError::HostError(format!( + "duplicate submitted host op {op_id}" + ))); + } + Ok(()) + } + + fn poll_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "unexpected external op {op_id}" + )))) + } + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + let poll = { + let future = match self.futures.get_mut(&op_id) { + Some(future) => future, + None => { + return Poll::Ready(Err(VmError::HostError(format!( + "unknown submitted host op {op_id}" + )))); + } + }; + let _guard = self.runtime.enter(); + future.as_mut().poll(cx) + }; + if poll.is_ready() { + self.futures.remove(&op_id); + } + poll + } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.futures.remove(&op_id); + } + + fn request_cancel_op( + &mut self, + op_id: HostOpId, + _reason: vm::operation::OperationCancelReason, + ) -> VmResult<()> { + self.futures.remove(&op_id); + Ok(()) + } + + fn poll_cancel_op(&mut self, _op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +pub(crate) fn install(vm: &mut Vm) { + vm.set_async_bridge(Box::new(TokioTestBridge::new())) + .expect("test async bridge should install"); +} diff --git a/tests/vm/drop_contract_tests.rs b/tests/vm/drop_contract_tests.rs index f6ead6a3..4170eaa2 100644 --- a/tests/vm/drop_contract_tests.rs +++ b/tests/vm/drop_contract_tests.rs @@ -679,7 +679,7 @@ fn reset_for_reuse_clears_all_locals_to_null() { let status = vm.run().expect("vm should run"); assert_eq!(status, VmStatus::Halted); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!( vm.drop_contract_event_count(), 0, diff --git a/tests/vm/runtime_state_edge_tests.rs b/tests/vm/runtime_state_edge_tests.rs index c4c461ab..9c9bba96 100644 --- a/tests/vm/runtime_state_edge_tests.rs +++ b/tests/vm/runtime_state_edge_tests.rs @@ -288,7 +288,7 @@ fn drop_contract_counts_overwrites_and_reset_clears_counter() { "expected drop contract to observe overwrite cleanup, got {after_run}" ); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); let after_reset = vm.drop_contract_event_count(); assert_eq!( after_reset, 0, @@ -321,7 +321,7 @@ fn reset_for_reuse_counts_cleanup_drops_from_live_state() { "cleanup should not have run before reset" ); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!( vm.drop_contract_event_count(), 5, diff --git a/tests/vm/vm_async_runtime_tests.rs b/tests/vm/vm_async_runtime_tests.rs index b58f8fe8..c7e4a1ec 100644 --- a/tests/vm/vm_async_runtime_tests.rs +++ b/tests/vm/vm_async_runtime_tests.rs @@ -96,6 +96,23 @@ impl HostAsyncBridge for TestAsyncBridge { .pending .remove(&op_id); } + + fn request_cancel_op( + &mut self, + op_id: HostOpId, + _reason: vm::operation::OperationCancelReason, + ) -> Result<(), VmError> { + self.cancel_op(op_id); + Ok(()) + } + + fn poll_cancel_op( + &mut self, + _op_id: HostOpId, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } } struct AsyncAddOneFunction { @@ -178,7 +195,8 @@ async fn async_host_call_waits_and_resumes_via_tokio_runtime() { Duration::from_millis(25), )), ); - vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops))); + vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops))) + .expect("test async bridge should install"); let status = vm.run().expect("vm should wait for async host operation"); let op_id = match status { @@ -218,14 +236,15 @@ async fn reset_cancels_pending_host_bridge_operation() { Duration::from_secs(60), )), ); - vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops.clone()))); + vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops.clone()))) + .expect("test async bridge should install"); assert!(matches!( vm.run().expect("pending call"), VmStatus::Waiting(_) )); assert_eq!(ops.lock().unwrap().pending.len(), 1); - vm.reset_for_reuse(); + let _ = vm.reset_for_reuse(); assert_eq!(ops.lock().unwrap().pending.len(), 0); assert_eq!(vm.waiting_host_op_id(), None); } @@ -244,7 +263,8 @@ async fn vm_waiting_on_async_host_op_does_not_block_tokio_tasks() { Duration::from_millis(40), )), ); - vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops))); + vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops))) + .expect("test async bridge should install"); let ticks = Arc::new(AtomicUsize::new(0)); let stop_ticker = Arc::new(AtomicBool::new(false)); diff --git a/tests/vm/vm_runtime_tests.rs b/tests/vm/vm_runtime_tests.rs index f9c6fcb4..cb920e28 100644 --- a/tests/vm/vm_runtime_tests.rs +++ b/tests/vm/vm_runtime_tests.rs @@ -1,7 +1,9 @@ #[path = "../common/mod.rs"] mod common; use common::*; +use std::sync::Arc; use vm::OpCode; +use vm::{HostImport, StandardSurfaceComposition}; fn non_yielding_returns_none(_: &[Value]) -> Result { Ok(CallOutcome::Return(vm::CallReturn::none())) @@ -248,6 +250,10 @@ fn call_can_wait_for_host_op_and_resume_without_replay() { let status = vm.run().expect("first run should wait on host op"); assert_eq!(status, VmStatus::Waiting(99)); + assert!( + !vm.is_reusable(), + "waiting host operation makes VM unavailable for pool reuse" + ); vm.complete_host_op(99, vec![Value::Int(7)]) .expect("host op completion should succeed"); @@ -421,7 +427,7 @@ fn runtime_sleep_host_import_can_be_overridden_by_host_binding() { impl HostFunction for RuntimeSleepOverride { fn call(&mut self, _vm: &mut Vm, args: &[Value]) -> Result { assert_eq!(args, &[Value::Int(3)]); - Ok(CallOutcome::Return(vec![Value::Int(7)].into())) + Ok(CallOutcome::Return(vec![Value::Bool(true)].into())) } } @@ -437,7 +443,7 @@ fn runtime_sleep_host_import_can_be_overridden_by_host_binding() { let status = vm.run().expect("vm should run"); assert_eq!(status, VmStatus::Halted); - assert_eq!(vm.stack(), &[Value::Int(7)]); + assert_eq!(vm.stack(), &[Value::Bool(true)]); } #[test] @@ -1340,3 +1346,81 @@ fn program_new_infers_locals_through_new_zero_operand_opcodes() { ); assert_eq!(program.local_count, 6); } + +struct CompositionValueHost(i64); + +impl HostArgsFunction for CompositionValueHost { + fn call(&mut self, args: &[Value]) -> vm::VmResult { + assert!(args.is_empty()); + Ok(CallOutcome::Return(vm::CallReturn::one(Value::Int(self.0)))) + } +} + +struct TestStandardComposition { + value: i64, +} + +impl StandardSurfaceComposition for TestStandardComposition { + fn import_in_standard(&self, import: &HostImport) -> bool { + import.name == "composition_value" + } + + fn ensure_surfaces( + &self, + imports: &[HostImport], + registry: &mut HostFunctionRegistry, + ) -> vm::VmResult { + if imports.iter().any(|import| self.import_in_standard(import)) { + let value = self.value; + registry.register_args("composition_value", 0, move || { + Box::new(CompositionValueHost(value)) + }); + Ok(true) + } else { + Ok(false) + } + } + + fn build_default_registry(&self) -> vm::VmResult { + Ok(HostFunctionRegistry::empty()) + } + + fn bind_default_name(&self, _vm: &mut Vm, _name: &str) -> bool { + false + } +} + +#[test] +fn registry_standard_composition_controls_vm_binding() { + let compiled = compile_source("fn composition_value() -> int; composition_value();") + .expect("composition test program should compile"); + let mut registry = HostFunctionRegistry::empty(); + registry.set_standard_composition(Arc::new(TestStandardComposition { value: 13 })); + + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("registry composition should stage and bind the import"); + assert_eq!(vm.run().expect("bound vm should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(13)]); + assert!(vm.standard_composition().is_some()); +} + +#[test] +fn standard_composition_isolated_per_vm_and_controls_default_binding() { + let compiled = compile_source("fn composition_value() -> int; composition_value();") + .expect("composition test program should compile"); + + let mut first = Vm::new(compiled.program.clone()); + first.set_standard_composition(Arc::new(TestStandardComposition { value: 7 })); + assert_eq!(first.run().expect("first vm should run"), VmStatus::Halted); + assert_eq!(first.stack(), &[Value::Int(7)]); + + let mut second = Vm::new(compiled.program); + second.set_standard_composition(Arc::new(TestStandardComposition { value: 42 })); + assert_eq!( + second.run().expect("second vm should run"), + VmStatus::Halted + ); + assert_eq!(second.stack(), &[Value::Int(42)]); +} diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index 9cd2a0f7..7bcc023e 100644 --- a/tests/wire/wire_tests.rs +++ b/tests/wire/wire_tests.rs @@ -2,9 +2,11 @@ use std::collections::HashMap; use vm::{ ArgInfo, Assembler, BuiltinFunction, BytecodeBuilder, DebugFunction, DebugInfo, - DisassembleOptions, HostImport, LineInfo, LocalInfo, Program, TypeMap, ValidationError, Value, - ValueType, WireError, builtin_call_index, decode_program, disassemble_vmbc, - disassemble_vmbc_with_options, encode_program, infer_local_count, validate_program, + DisassembleOptions, HostApiBuilder, HostFunctionSchema, HostImport, HostImportSchema, + HostParamPassing, HostParamSchema, HostTypeSchema, LineInfo, LocalInfo, Program, + ResourceTypeKey, ResourceTypeSchema, TypeMap, ValidationError, Value, ValueType, WireError, + builtin_call_index, decode_program, disassemble_vmbc, disassemble_vmbc_with_options, + encode_program, infer_local_count, validate_program, }; #[test] @@ -55,7 +57,7 @@ fn wire_roundtrip_preserves_constants_and_code() { }); let encoded = encode_program(&program).expect("encode should succeed"); - assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 11); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 12); let decoded = decode_program(&encoded).expect("decode should succeed"); assert_eq!(decoded.constants, program.constants); @@ -65,6 +67,80 @@ fn wire_roundtrip_preserves_constants_and_code() { assert_eq!(decoded.type_map, program.type_map); } +#[test] +fn wire_v11_legacy_imports_decode_without_schema_metadata() { + let import = HostImport { + name: "legacy::import".to_string(), + arity: 0, + return_type: ValueType::Unknown, + }; + let program = Program::with_imports_and_debug( + Vec::new(), + vec![vm::OpCode::Ret as u8], + vec![import.clone()], + None, + ); + let encoded = encode_program(&program).expect("v12 encoding should succeed"); + let marker_offset = 8 + 4 + 4 + program.code.len() + 4 + 4 + import.name.len() + 2; + assert_eq!(encoded[marker_offset], 0); + let mut legacy = encoded; + legacy.drain(marker_offset..marker_offset + 1); + 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()); +} + +fn rich_host_import_schema() -> HostImportSchema { + let resource = ResourceTypeKey::new("wire.resource").expect("resource key"); + let callback = HostTypeSchema::Callable { + params: vec![HostTypeSchema::Array(Box::new(HostTypeSchema::Resource( + resource.clone(), + )))], + result: Box::new(HostTypeSchema::Optional(Box::new(HostTypeSchema::String))), + }; + let function = HostFunctionSchema::with_return( + "demo::wire_schema", + vec![HostParamSchema::with_passing( + "callback", + callback, + HostParamPassing::Borrow, + )], + HostTypeSchema::Map(Box::new(HostTypeSchema::Callable { + params: vec![HostTypeSchema::Int], + result: Box::new(HostTypeSchema::Resource(resource.clone())), + })), + ); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(resource, "wire resource")); + builder.function(function.clone()); + let catalog = builder.build().expect("catalog"); + HostImportSchema::from_function(&catalog, &function) +} + +#[test] +fn wire_roundtrip_preserves_full_host_import_schema_identity() { + let schema = rich_host_import_schema(); + let program = Program::with_imports_and_debug( + Vec::new(), + vec![vm::OpCode::Ret as u8], + vec![HostImport { + name: schema.name.clone(), + arity: schema.arity() as u8, + return_type: ValueType::Map, + }], + None, + ) + .with_host_import_schemas(vec![schema.clone()]) + .expect("schema metadata should align"); + + let encoded = encode_program(&program).expect("full schema should encode"); + let decoded = decode_program(&encoded).expect("full schema should decode"); + + assert_eq!(decoded.host_import_schemas(), &[Some(schema)]); +} + #[test] fn wire_roundtrip_recovers_locals_reserved_by_type_metadata() { let local_count = 8;