diff --git a/Cargo.lock b/Cargo.lock index 087cd7ee..7fa8a031 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -330,6 +342,18 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fd-lock" version = "4.0.4" @@ -467,6 +491,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -482,6 +515,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -749,6 +791,17 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -881,6 +934,7 @@ dependencies = [ "regex", "reqwest", "rt-format", + "rusqlite", "rustyline", "self_cell", "serde", @@ -920,6 +974,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1165,6 +1225,20 @@ dependencies = [ "regex", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1621,6 +1695,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1880,6 +1966,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 098701b4..2cda5b84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,8 @@ name = "vm" [features] default = ["runtime", "cli", "cranelift-jit"] runtime = [] -http-client = ["dep:reqwest", "dep:url", "dep:tokio", "dep:futures-util"] +http-client = ["runtime", "dep:reqwest", "dep:url", "dep:tokio", "dep:futures-util"] +sqlite = ["runtime", "dep:rusqlite"] edge-abi = [ "dep:edge_abi", "edge_abi/console", @@ -62,9 +63,10 @@ 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" } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"], optional = true } +rusqlite = { version = "0.32", default-features = false, features = ["bundled", "hooks", "limits"], optional = true } url = { version = "2", optional = true } futures-util = { version = "0.3", optional = true } -tokio = { version = "1", features = ["rt", "net", "time"], optional = true } +tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync"], optional = true } edge_abi = { package = "pd-edge-abi", version = "0.1.1", default-features = false, optional = true } futures-channel = "0.3" paste = "1" @@ -82,6 +84,7 @@ windows-sys = { version = "0.59", features = ["Win32_System_Diagnostics_Debug", libc = "0.2" [dev-dependencies] +futures-util = "0.3" syn = { version = "2", features = ["full"] } tokio = { version = "1", features = ["macros", "rt", "time", "sync"] } @@ -93,7 +96,12 @@ required-features = ["cranelift-jit"] [[test]] name = "http_host_tests" path = "tests/vm/http_host_tests.rs" -required-features = ["http-client"] +required-features = ["runtime", "http-client"] + +[[test]] +name = "sqlite_host_tests" +path = "tests/vm/sqlite_host_tests.rs" +required-features = ["sqlite"] [build-dependencies] syn = { version = "2", features = ["full"] } diff --git a/build.rs b/build.rs index 6a7e1dc0..64979a9c 100644 --- a/build.rs +++ b/build.rs @@ -149,7 +149,7 @@ fn main() { println!("cargo:rerun-if-changed={}", catalog_path.display()); let catalog = parse_catalog(&catalog_path); - let host_sources = [ + let mut host_sources = vec![ SourceSpec { path: "src/builtins/runtime/host.rs".to_string(), module: "host".to_string(), @@ -160,7 +160,19 @@ fn main() { module: "http".to_string(), category: SourceCategory::DefaultHost, }, + SourceSpec { + path: "src/builtins/runtime/context_host.rs".to_string(), + module: "context_host".to_string(), + category: SourceCategory::DefaultHost, + }, ]; + if env::var_os("CARGO_FEATURE_SQLITE").is_some() { + host_sources.push(SourceSpec { + path: "src/builtins/runtime/sqlite.rs".to_string(), + module: "sqlite".to_string(), + category: SourceCategory::DefaultHost, + }); + } let builtin_sources = builtin_source_specs(&namespaces); let core_sources = [SourceSpec { path: "src/builtins/runtime/core.rs".to_string(), @@ -941,6 +953,7 @@ fn render_builtin_catalog( writeln!(&mut out, "impl BuiltinFunction {{").unwrap(); render_builtin_name_method(&mut out, &builtin_variant_order, &actual_builtin_by_variant); + render_builtin_capability_method(&mut out, builtin_callables); render_builtin_arity_method(&mut out, &builtin_variant_order, &actual_builtin_by_variant); render_builtin_accepts_arity_method( &mut out, @@ -1082,6 +1095,12 @@ fn render_builtin_runtime_dispatch( ) .unwrap(); } + writeln!( + &mut out, + " registry.mark_runtime_owned_pending({:?});", + callable.name + ) + .unwrap(); } writeln!(&mut out, "}}").unwrap(); writeln!(&mut out).unwrap(); @@ -1098,6 +1117,12 @@ fn render_builtin_runtime_dispatch( .render_bind_static_call(&callable.name, &host_wrapper_adapter_name(callable)); writeln!(&mut out, " {:?} => {{", callable.name).unwrap(); writeln!(&mut out, " {bind_call}").unwrap(); + writeln!( + &mut out, + " vm.mark_runtime_owned_pending_binding({:?});", + callable.name + ) + .unwrap(); writeln!(&mut out, " true").unwrap(); writeln!(&mut out, " }}").unwrap(); } @@ -1415,6 +1440,35 @@ fn render_builtin_name_method( writeln!(out).unwrap(); } +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 render_builtin_arity_method( out: &mut String, builtin_variant_order: &[String], diff --git a/crates/rustscript/Cargo.toml b/crates/rustscript/Cargo.toml index 6279a1e1..8d72371c 100644 --- a/crates/rustscript/Cargo.toml +++ b/crates/rustscript/Cargo.toml @@ -16,6 +16,8 @@ runtime = ["pd_vm_crate/runtime"] edge-abi = ["pd_vm_crate/edge-abi"] cli = ["pd_vm_crate/cli"] cranelift-jit = ["pd_vm_crate/cranelift-jit"] +http-client = ["runtime", "pd_vm_crate/http-client"] +sqlite = ["pd_vm_crate/sqlite"] [dependencies] -pd_vm_crate = { package = "pd-vm", path = "../..", version = ">=0.1.0, <1.0.0" } +pd_vm_crate = { package = "pd-vm", path = "../..", version = "=0.1.0", default-features = false } diff --git a/crates/rustscript/tests/alias_smoke.rs b/crates/rustscript/tests/alias_smoke.rs index c58bb302..1a63ee26 100644 --- a/crates/rustscript/tests/alias_smoke.rs +++ b/crates/rustscript/tests/alias_smoke.rs @@ -21,3 +21,38 @@ fn alias_exports_op_code() { let _ = rustscript::OpCode::Nop; let _ = rustscript::OpCode::Add; } + +#[cfg(feature = "runtime")] +#[test] +fn alias_exports_public_runtime_event_contract() { + fn accept_sink(_sink: S) {} + + struct Sink; + impl rustscript::EventSink for Sink { + fn emit(&mut self, _payload: rustscript::EventPayload) -> rustscript::RuntimeResult<()> { + Ok(()) + } + } + + accept_sink(Sink); +} + +#[cfg(feature = "http-client")] +#[test] +fn alias_http_client_includes_runtime_contract() { + fn accept_runtime_result(_result: rustscript::RuntimeResult<()>) {} + + accept_runtime_result(Ok(())); +} + +#[cfg(feature = "sqlite")] +#[test] +fn alias_exports_public_sqlite_configuration() { + let program = rustscript::compile_source("0;") + .expect("minimal alias SQLite program should compile") + .program; + let mut vm = rustscript::Vm::new(program); + vm.configure_sqlite(rustscript::SqlitePolicy::default()); + let _limits = rustscript::SqliteLimits::default(); + vm.clear_sqlite_configuration(); +} diff --git a/docs/callable-runtime.md b/docs/callable-runtime.md index ed0cfd4b..d71033d8 100644 --- a/docs/callable-runtime.md +++ b/docs/callable-runtime.md @@ -9,7 +9,7 @@ RustScript bytecode format version 11 (VMBC v11) introduces runtime script call - callable environments are bound through the internal builtin call path; callable creation adds no bytecode opcode. - `ret` completes the active script frame. A nested frame leaves exactly one result at the caller segment base, using `null` when the body produced no value. Root `ret` keeps the historical program-result stack behavior. -VMBC v11 is a hard format boundary. Decoders reject all earlier versions (v10 and below) with a deterministic unsupported-version error; there is no compatibility decoder and no old-ID alias. The stream includes script-function entry ranges, callable prototypes, function regions, root callable bindings, and call indices drawn from the static builtin catalog. PDRC v6 recordings and AOT artifacts (format 7, ABI 6) use their corresponding bumped versions and include callable metadata in cache identity. +VMBC v11 is a hard format boundary. Decoders reject all earlier versions (v10 and below) with a deterministic unsupported-version error; there is no compatibility decoder and no old-ID alias. The stream includes script-function entry ranges, callable prototypes, function regions, root callable bindings, and call indices drawn from the static builtin catalog. PDRC v6 recordings and AOT artifacts (format 7, ABI 7) use their corresponding bumped versions and include callable metadata in cache identity. ## Static builtin IDs diff --git a/src/builtins/runtime/cancellation.rs b/src/builtins/runtime/cancellation.rs new file mode 100644 index 00000000..ef8dbc67 --- /dev/null +++ b/src/builtins/runtime/cancellation.rs @@ -0,0 +1,1006 @@ +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Instant; + +use super::error::{RuntimeError, RuntimeErrorCode, RuntimeResult}; +use super::resource::ResourceHandle; + +pub const DEFAULT_MAX_PENDING_OPERATIONS: usize = 64; +const TERMINAL_BIT: u8 = 0x80; +const REASON_MASK: u8 = !TERMINAL_BIT; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct OperationId(u64); + +impl OperationId { + pub fn from_raw(raw: u64) -> RuntimeResult { + if raw == 0 { + return Err(RuntimeError::new( + RuntimeErrorCode::OperationIdExhausted, + "runtime::operation", + "operation id zero is reserved", + )); + } + Ok(Self(raw)) + } + + pub const fn raw(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum OperationOwner { + HostBridge, + Io, + Http, + #[cfg(feature = "sqlite")] + Sqlite, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum CancellationReason { + Requested = 1, + Deadline = 2, + VmReset = 3, + Parent = 4, + ResourceClosed = 5, +} + +impl CancellationReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::Requested => "requested", + Self::Deadline => "deadline", + Self::VmReset => "vm_reset", + Self::Parent => "parent", + Self::ResourceClosed => "resource_closed", + } + } + + fn from_raw(raw: u8) -> Option { + match raw { + 1 => Some(Self::Requested), + 2 => Some(Self::Deadline), + 3 => Some(Self::VmReset), + 4 => Some(Self::Parent), + 5 => Some(Self::ResourceClosed), + _ => None, + } + } +} + +struct CancellationSignal { + state: AtomicU8, + deadline: Option, + children: Mutex>>, + propagation_error: Mutex>, +} + +impl CancellationSignal { + fn mark_cancelled(&self, reason: CancellationReason) -> bool { + self.state + .compare_exchange(0, reason as u8, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + fn cancel(&self, reason: CancellationReason) -> (bool, Option) { + let transitioned = self.mark_cancelled(reason); + let mut first_error = None; + if transitioned { + let children = self + .children + .lock() + .expect("cancellation children lock should not be poisoned") + .iter() + .filter_map(Weak::upgrade) + .collect::>(); + for child in children { + if let Err(error) = child.cancel(reason) + && first_error.is_none() + { + first_error = Some(error); + } + } + } + (transitioned, first_error) + } + + fn store_propagation_error(&self, error: Option) { + if let Some(error) = error { + let mut stored = self + .propagation_error + .lock() + .expect("cancellation propagation error lock should not be poisoned"); + if stored.is_none() { + *stored = Some(error); + } + } + } + + fn take_propagation_error(&self) -> Option { + self.propagation_error + .lock() + .expect("cancellation propagation error lock should not be poisoned") + .take() + } + + fn reason(&self) -> Option { + let state = self.state.load(Ordering::Acquire); + if state == 0 + && self + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + let (_, error) = self.cancel(CancellationReason::Deadline); + self.store_propagation_error(error); + } + CancellationReason::from_raw(self.state.load(Ordering::Acquire) & REASON_MASK) + } + + fn finish_success(&self) -> bool { + self.state + .compare_exchange(0, TERMINAL_BIT, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + fn finish_cancelled(&self, requested: CancellationReason) -> CancellationReason { + loop { + let state = self.state.load(Ordering::Acquire); + let reason = CancellationReason::from_raw(state & REASON_MASK).unwrap_or(requested); + if state & TERMINAL_BIT != 0 { + return reason; + } + let terminal = TERMINAL_BIT | reason as u8; + if self + .state + .compare_exchange(state, terminal, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return reason; + } + } + } +} + +#[derive(Clone)] +pub struct CancellationToken { + id: OperationId, + signal: Arc, +} + +impl CancellationToken { + pub(crate) fn root() -> Self { + Self { + id: OperationId(u64::MAX), + signal: Arc::new(CancellationSignal { + state: AtomicU8::new(0), + deadline: None, + children: Mutex::new(Vec::new()), + propagation_error: Mutex::new(None), + }), + } + } + + pub fn is_cancelled(&self) -> bool { + self.reason().is_some() + } + + pub fn reason(&self) -> Option { + self.signal.reason() + } + + pub fn cancel(&self, reason: CancellationReason) -> bool { + let (transitioned, error) = self.signal.cancel(reason); + self.signal.store_propagation_error(error); + transitioned + } + + pub(crate) fn take_propagation_error(&self) -> Option { + self.signal.take_propagation_error() + } + + pub(crate) fn mark_cancelled(&self, reason: CancellationReason) -> bool { + self.signal.mark_cancelled(reason) + } + + pub fn check(&self) -> RuntimeResult<()> { + let Some(reason) = self.reason() else { + return Ok(()); + }; + Err(RuntimeError::new( + RuntimeErrorCode::OperationCancelled, + "runtime::operation", + format!( + "operation {} was cancelled ({})", + self.id.raw(), + reason.as_str() + ), + ) + .with_value(self.id.raw())) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OperationStatus { + Pending, + Completed, + Cancelled(CancellationReason), + Failed(RuntimeError), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OperationEnd { + Completed, + Cancelled(CancellationReason), + Failed(RuntimeError), +} + +pub type OperationCleanup = Box RuntimeResult<()> + Send + 'static>; + +struct OperationInner { + status: OperationStatus, + cleanup: Option, + payload: Option, + resource: Option, +} + +struct RegistryInner { + operations: Mutex>, +} + +struct OperationCore { + id: OperationId, + owner: OperationOwner, + token: CancellationToken, + inner: Mutex, +} + +impl OperationCore { + fn status(&self) -> OperationStatus { + self.inner + .lock() + .expect("operation state lock should not be poisoned") + .status + .clone() + } + + fn cancel(&self, reason: CancellationReason) -> RuntimeResult { + let _ = self.token.reason(); + let (_, child_error) = self.token.signal.cancel(reason); + let child_error = child_error.or_else(|| self.token.signal.take_propagation_error()); + let cleanup = { + let mut inner = self + .inner + .lock() + .expect("operation state lock should not be poisoned"); + if !matches!(inner.status, OperationStatus::Pending) { + return Ok(false); + } + let reason = self.token.reason().unwrap_or(reason); + let reason = self.token.signal.finish_cancelled(reason); + inner.status = OperationStatus::Cancelled(reason); + (inner.cleanup.take(), reason) + }; + let cleanup_result = if let (Some(cleanup), reason) = cleanup { + cleanup(OperationEnd::Cancelled(reason)).map_err(|error| { + RuntimeError::new( + RuntimeErrorCode::OperationCleanupFailed, + "runtime::operation", + error.to_string(), + ) + .with_value(self.id.raw()) + }) + } else { + Ok(()) + }; + match (child_error, cleanup_result) { + (Some(error), _) => Err(error), + (None, Err(error)) => Err(error), + (None, Ok(())) => Ok(true), + } + } + + fn complete(&self) -> RuntimeResult { + if let Some(reason) = self.token.reason() { + return self.cancel(reason); + } + self.finish(OperationStatus::Completed, OperationEnd::Completed) + } + + fn fail(&self, error: RuntimeError) -> RuntimeResult { + if let Some(reason) = self.token.reason() { + return self.cancel(reason); + } + self.finish( + OperationStatus::Failed(error.clone()), + OperationEnd::Failed(error), + ) + } + + fn finish(&self, status: OperationStatus, end: OperationEnd) -> RuntimeResult { + let (cleanup, end) = { + let mut inner = self + .inner + .lock() + .expect("operation state lock should not be poisoned"); + if !matches!(inner.status, OperationStatus::Pending) { + return Ok(false); + } + let end = if self.token.signal.finish_success() { + inner.status = status; + end + } else { + let reason = self + .token + .reason() + .expect("a failed success transition must carry cancellation"); + let reason = self.token.signal.finish_cancelled(reason); + inner.status = OperationStatus::Cancelled(reason); + OperationEnd::Cancelled(reason) + }; + (inner.cleanup.take(), end) + }; + let cleanup_result = if let Some(cleanup) = cleanup { + cleanup(end).map_err(|error| { + RuntimeError::new( + RuntimeErrorCode::OperationCleanupFailed, + "runtime::operation", + error.to_string(), + ) + .with_value(self.id.raw()) + }) + } else { + Ok(()) + }; + cleanup_result?; + Ok(true) + } + + fn attach_parent(self: &Arc, parent: &CancellationToken) -> RuntimeResult<()> { + { + let mut children = parent + .signal + .children + .lock() + .expect("cancellation children lock should not be poisoned"); + children.retain(|child| child.strong_count() > 0); + children.push(Arc::downgrade(self)); + } + if parent.is_cancelled() { + self.cancel(parent.reason().unwrap_or(CancellationReason::Parent))?; + } + Ok(()) + } +} + +#[derive(Clone)] +pub struct OperationState { + core: Arc, +} + +impl fmt::Debug for OperationState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OperationState") + .field("id", &self.id()) + .field("owner", &self.owner()) + .field("status", &self.status()) + .finish() + } +} + +impl OperationState { + pub fn id(&self) -> OperationId { + self.core.id + } + + pub fn owner(&self) -> OperationOwner { + self.core.owner + } + + pub fn token(&self) -> CancellationToken { + self.core.token.clone() + } + + pub fn status(&self) -> OperationStatus { + self.core.status() + } + + pub fn set_payload(&self, payload: ResourceHandle) { + self.core + .inner + .lock() + .expect("operation state lock should not be poisoned") + .payload = Some(payload); + } + + #[cfg(feature = "sqlite")] + pub(crate) fn set_cleanup(&self, cleanup: OperationCleanup) -> RuntimeResult<()> { + let mut inner = self + .core + .inner + .lock() + .expect("operation state lock should not be poisoned"); + if !matches!(inner.status, OperationStatus::Pending) { + return Err(RuntimeError::new( + RuntimeErrorCode::OperationAlreadyFinished, + "runtime::operation", + "cannot attach cleanup to a terminal operation", + ) + .with_value(self.id().raw())); + } + if inner.cleanup.is_some() { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::operation", + "operation cleanup is already configured", + ) + .with_value(self.id().raw())); + } + inner.cleanup = Some(cleanup); + Ok(()) + } + + pub fn payload(&self) -> Option { + self.core + .inner + .lock() + .expect("operation state lock should not be poisoned") + .payload + } + + pub fn set_resource(&self, resource: ResourceHandle) { + self.core + .inner + .lock() + .expect("operation state lock should not be poisoned") + .resource = Some(resource); + } + + pub fn resource(&self) -> Option { + self.core + .inner + .lock() + .expect("operation state lock should not be poisoned") + .resource + } + + pub fn cancel(&self, reason: CancellationReason) -> RuntimeResult { + self.core.cancel(reason) + } + + pub fn complete(&self) -> RuntimeResult { + self.core.complete() + } + + pub fn fail(&self, error: RuntimeError) -> RuntimeResult { + self.core.fail(error) + } + + fn build( + id: OperationId, + owner: OperationOwner, + deadline: Option, + cleanup: Option, + ) -> Self { + let token = CancellationToken { + id, + signal: Arc::new(CancellationSignal { + state: AtomicU8::new(0), + deadline, + children: Mutex::new(Vec::new()), + propagation_error: Mutex::new(None), + }), + }; + Self { + core: Arc::new(OperationCore { + id, + owner, + token, + inner: Mutex::new(OperationInner { + status: OperationStatus::Pending, + cleanup, + payload: None, + resource: None, + }), + }), + } + } +} + +pub struct OperationRegistry { + max_pending: usize, + next_id: u64, + last_external_id: u64, + inner: Arc, +} + +impl OperationRegistry { + pub fn with_limit(max_pending: usize) -> RuntimeResult { + if max_pending == 0 { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::operation", + "operation registry capacity must be positive", + )); + } + Ok(Self { + max_pending, + next_id: 1, + last_external_id: 0, + inner: Arc::new(RegistryInner { + operations: Mutex::new(HashMap::new()), + }), + }) + } + + pub fn active_count(&self) -> usize { + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .values() + .filter(|operation| !matches!(operation.status(), OperationStatus::Cancelled(_))) + .count() + } + + pub(crate) fn allocate_id(&mut self) -> RuntimeResult { + let id = OperationId::from_raw(self.next_id)?; + self.next_id = self.next_id.checked_add(1).ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::OperationIdExhausted, + "runtime::operation", + "operation id space exhausted", + ) + })?; + Ok(id) + } + + pub fn start_owned( + &mut self, + owner: OperationOwner, + parent: Option<&CancellationToken>, + deadline: Option, + cleanup: Option, + ) -> RuntimeResult { + if self.active_count() >= self.max_pending { + return Err(RuntimeError::new( + RuntimeErrorCode::OperationLimitExceeded, + "runtime::operation", + "pending operation capacity has been reached", + ) + .with_limit(self.max_pending)); + } + let id = self.allocate_id()?; + let operation = OperationState::build(id, owner, deadline, cleanup); + if let Some(parent) = parent { + operation.core.attach_parent(parent)?; + } + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .insert(id, operation.clone()); + Ok(operation) + } + + #[cfg(test)] + pub fn register_external( + &mut self, + id: OperationId, + owner: OperationOwner, + parent: Option<&CancellationToken>, + deadline: Option, + cleanup: Option, + ) -> RuntimeResult { + self.retire_external_id(id)?; + self.register_retired_external(id, owner, parent, deadline, cleanup) + } + + pub(crate) fn retire_external_id(&mut self, id: OperationId) -> RuntimeResult<()> { + if id.raw() <= self.last_external_id { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::operation", + format!( + "external operation {} is not newer than the last external operation {}", + id.raw(), + self.last_external_id + ), + ) + .with_value(id.raw())); + } + let next_id = id.raw().checked_add(1).ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::OperationIdExhausted, + "runtime::operation", + "operation id space exhausted", + ) + })?; + self.last_external_id = id.raw(); + self.next_id = self.next_id.max(next_id); + Ok(()) + } + + pub(crate) fn register_retired_external( + &mut self, + id: OperationId, + owner: OperationOwner, + parent: Option<&CancellationToken>, + deadline: Option, + cleanup: Option, + ) -> RuntimeResult { + if id.raw() != self.last_external_id { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::operation", + format!("external operation {} has not just been retired", id.raw()), + ) + .with_value(id.raw())); + } + if self.active_count() >= self.max_pending { + return Err(RuntimeError::new( + RuntimeErrorCode::OperationLimitExceeded, + "runtime::operation", + "pending operation capacity has been reached", + ) + .with_limit(self.max_pending)); + } + let registered = self + .inner + .operations + .lock() + .expect("operation registry lock should not be poisoned"); + if registered.contains_key(&id) { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::operation", + format!("operation {} is already registered", id.raw()), + ) + .with_value(id.raw())); + } + let operation = OperationState::build(id, owner, deadline, cleanup); + drop(registered); + if let Some(parent) = parent { + operation.core.attach_parent(parent)?; + } + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .insert(id, operation.clone()); + Ok(operation) + } + + pub fn get(&self, id: OperationId) -> RuntimeResult { + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .get(&id) + .cloned() + .ok_or_else(|| operation_not_found(id)) + } + + pub fn operations_by_owner(&self, owner: OperationOwner) -> Vec { + let operations = self.registered_operations(); + operations + .into_iter() + .filter(|operation| operation.owner() == owner) + .collect() + } + + pub fn operations_for_resource(&self, resource: ResourceHandle) -> Vec { + let operations = self.registered_operations(); + operations + .into_iter() + .filter(|operation| operation.resource() == Some(resource)) + .collect() + } + + pub fn cancel(&mut self, id: OperationId, reason: CancellationReason) -> RuntimeResult { + self.take_operation(id)?.cancel(reason) + } + + pub fn complete(&mut self, id: OperationId) -> RuntimeResult { + self.take_operation(id)?.complete() + } + + pub fn fail(&mut self, id: OperationId, error: RuntimeError) -> RuntimeResult { + self.take_operation(id)?.fail(error) + } + + pub fn cancel_all(&mut self, reason: CancellationReason) -> RuntimeResult { + let operations = { + let mut registered = self + .inner + .operations + .lock() + .expect("operation registry lock should not be poisoned"); + std::mem::take(&mut *registered) + }; + let operations = operations.into_values().collect::>(); + for operation in &operations { + operation.token().mark_cancelled(reason); + } + let mut first_error = None; + for operation in &operations { + if let Err(error) = operation.cancel(reason) { + first_error.get_or_insert(error); + } + } + match first_error { + Some(error) => Err(error), + None => Ok(operations + .iter() + .filter(|operation| matches!(operation.status(), OperationStatus::Cancelled(_))) + .count()), + } + } + + fn registered_operations(&self) -> Vec { + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .values() + .cloned() + .collect() + } + + fn take_operation(&mut self, id: OperationId) -> RuntimeResult { + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .remove(&id) + .ok_or_else(|| operation_not_found(id)) + } +} + +impl Default for OperationRegistry { + fn default() -> Self { + Self::with_limit(DEFAULT_MAX_PENDING_OPERATIONS) + .expect("default operation registry configuration should be valid") + } +} + +impl Drop for OperationRegistry { + fn drop(&mut self) { + let _ = self.cancel_all(CancellationReason::VmReset); + } +} + +fn operation_not_found(id: OperationId) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::OperationNotFound, + "runtime::operation", + format!("operation {} is not registered", id.raw()), + ) + .with_value(id.raw()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + use super::super::error::{RuntimeError, RuntimeErrorCode}; + use super::{ + CancellationReason, OperationId, OperationOwner, OperationRegistry, OperationStatus, + }; + + #[test] + fn token_reports_the_first_cancellation_reason() { + let mut registry = OperationRegistry::with_limit(4).expect("registry should be valid"); + let operation = registry + .start_owned(OperationOwner::Io, None, None, None) + .expect("operation should start"); + let token = operation.token(); + assert!(token.cancel(CancellationReason::Deadline)); + assert!(!token.cancel(CancellationReason::Parent)); + assert_eq!(token.reason(), Some(CancellationReason::Deadline)); + assert_eq!(operation.status(), OperationStatus::Pending); + } + + #[test] + fn parent_cancellation_propagates_and_deadline_is_structured() { + let mut registry = OperationRegistry::with_limit(4).expect("registry should be valid"); + let parent = registry + .start_owned(OperationOwner::Http, None, None, None) + .expect("parent should start"); + let child = registry + .start_owned(OperationOwner::Http, Some(&parent.token()), None, None) + .expect("child should start"); + assert!( + parent + .cancel(CancellationReason::Requested) + .expect("parent cancellation should succeed") + ); + assert_eq!(child.token().reason(), Some(CancellationReason::Requested)); + + let deadline_parent = registry + .start_owned(OperationOwner::Io, None, None, None) + .expect("deadline parent should start"); + let expired = registry + .start_owned( + OperationOwner::Io, + Some(&deadline_parent.token()), + Some(Instant::now() - Duration::from_millis(1)), + None, + ) + .expect("deadline child should start"); + assert_eq!(expired.token().reason(), Some(CancellationReason::Deadline)); + } + + #[test] + fn cancel_all_counts_children_cancelled_by_parent_propagation() { + let mut registry = OperationRegistry::with_limit(2).expect("registry should be valid"); + let parent = registry + .start_owned(OperationOwner::Http, None, None, None) + .expect("parent should start"); + let child = registry + .start_owned(OperationOwner::Io, Some(&parent.token()), None, None) + .expect("child should start"); + + assert_eq!( + registry + .cancel_all(CancellationReason::VmReset) + .expect("all operations should cancel"), + 2 + ); + assert_eq!( + parent.status(), + OperationStatus::Cancelled(CancellationReason::VmReset) + ); + assert!(matches!( + child.status(), + OperationStatus::Cancelled(CancellationReason::Parent | CancellationReason::VmReset) + )); + assert_eq!(registry.active_count(), 0); + } + + #[test] + fn parent_cancellation_finishes_registered_children_and_releases_capacity() { + let child_cleanup_count = Arc::new(AtomicUsize::new(0)); + let cleanup_count = Arc::clone(&child_cleanup_count); + let mut registry = OperationRegistry::with_limit(2).expect("registry should be valid"); + let parent = registry + .start_owned(OperationOwner::Http, None, None, None) + .expect("parent should start"); + let child = registry + .start_owned( + OperationOwner::Io, + Some(&parent.token()), + None, + Some(Box::new(move |end| { + assert_eq!( + end, + super::OperationEnd::Cancelled(CancellationReason::Requested) + ); + cleanup_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + })), + ) + .expect("child should start"); + + assert!( + parent + .cancel(CancellationReason::Requested) + .expect("parent should cancel") + ); + + assert_eq!( + child.status(), + OperationStatus::Cancelled(CancellationReason::Requested) + ); + assert_eq!(child_cleanup_count.load(Ordering::SeqCst), 1); + assert_eq!(registry.active_count(), 0); + assert!(registry.get(child.id()).is_ok()); + registry + .start_owned(OperationOwner::Io, None, None, None) + .expect("parent cancellation should release registry capacity"); + assert!( + !child + .cancel(CancellationReason::Requested) + .expect("child cancellation should remain idempotent") + ); + assert_eq!(child_cleanup_count.load(Ordering::SeqCst), 1); + } + + #[test] + fn parent_cancellation_propagates_child_cleanup_failure() { + let mut registry = OperationRegistry::with_limit(2).expect("registry should be valid"); + let parent = registry + .start_owned(OperationOwner::Http, None, None, None) + .expect("parent should start"); + registry + .start_owned( + OperationOwner::Io, + Some(&parent.token()), + None, + Some(Box::new(|_| { + Err(RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "test::cleanup", + "child cleanup failed", + )) + })), + ) + .expect("child should start"); + + let error = parent + .cancel(CancellationReason::Requested) + .expect_err("child cleanup failure should propagate"); + assert_eq!(error.code(), RuntimeErrorCode::OperationCleanupFailed); + } + + #[test] + fn completed_external_operation_ids_cannot_be_reused() { + let mut registry = OperationRegistry::with_limit(2).expect("registry should be valid"); + let id = OperationId::from_raw(7).expect("operation id should be valid"); + registry + .register_external(id, OperationOwner::HostBridge, None, None, None) + .expect("first external operation should register"); + registry + .complete(id) + .expect("external operation should complete"); + + let error = registry + .register_external(id, OperationOwner::HostBridge, None, None, None) + .expect_err("completed external operation id must remain retired"); + assert_eq!(error.code(), RuntimeErrorCode::InvalidConfiguration); + } + + #[test] + fn rejected_external_operation_ids_are_retired() { + let mut registry = OperationRegistry::with_limit(1).expect("registry should be valid"); + let active = registry + .start_owned(OperationOwner::Io, None, None, None) + .expect("capacity should be occupied"); + let id = OperationId::from_raw(7).expect("operation id should be valid"); + let error = registry + .register_external(id, OperationOwner::HostBridge, None, None, None) + .expect_err("external operation should exceed capacity"); + assert_eq!(error.code(), RuntimeErrorCode::OperationLimitExceeded); + registry + .complete(active.id()) + .expect("capacity should be released"); + + let error = registry + .register_external(id, OperationOwner::HostBridge, None, None, None) + .expect_err("rejected external operation id must remain retired"); + assert_eq!(error.code(), RuntimeErrorCode::InvalidConfiguration); + } + + #[test] + fn attaching_children_prunes_completed_parent_links() { + let mut registry = OperationRegistry::with_limit(2).expect("registry should be valid"); + let parent = registry + .start_owned(OperationOwner::Http, None, None, None) + .expect("parent should start"); + + for _ in 0..32 { + let child = registry + .start_owned(OperationOwner::Io, Some(&parent.token()), None, None) + .expect("child should start"); + registry + .complete(child.id()) + .expect("child should complete"); + } + + let live_links = parent + .token() + .signal + .children + .lock() + .expect("children lock") + .len(); + assert!(live_links <= 1, "completed child links should be pruned"); + } +} diff --git a/src/builtins/runtime/context.rs b/src/builtins/runtime/context.rs new file mode 100644 index 00000000..2588e23b --- /dev/null +++ b/src/builtins/runtime/context.rs @@ -0,0 +1,137 @@ +use super::error::{RuntimeError, RuntimeErrorCode, RuntimeResult}; +use super::event::{EventEmitter, EventLimits, EventReceipt, EventSink}; +use crate::vm::{Value, VmResult}; + +pub const RUNTIME_INPUT_NAME: &str = "runtime::input"; +#[allow(dead_code)] +pub const RUNTIME_EMIT_NAME: &str = "runtime::emit"; + +#[allow(dead_code)] +pub type RuntimeEventSink = dyn EventSink; + +/// Configuration for one VM/run-scoped generic runtime context. +#[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 input and generic event transport hooks. +/// +/// The context stores values as VM [`Value`]s and delegates event persistence/delivery to the +/// embedding. It has no knowledge of sessions, providers, platforms, or event names. +pub struct RuntimeContext { + input: Option, + events: EventEmitter, +} + +#[allow(dead_code)] +impl RuntimeContext { + pub fn with_config(config: RuntimeContextConfig) -> RuntimeResult { + Ok(Self { + input: None, + events: EventEmitter::new(config.event_limits()), + }) + } + + pub fn config(&self) -> RuntimeContextConfig { + RuntimeContextConfig::new(self.events.limits()) + } + + pub fn set_input(&mut self, value: Value) -> RuntimeResult<()> { + self.input = Some(value); + Ok(()) + } + + pub fn clear_input(&mut self) { + self.input = None; + } + + pub fn reset_for_reuse(&mut self) { + self.input = None; + self.events.reset_for_reuse(); + } + + pub fn input(&self) -> RuntimeResult { + self.input.clone().ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::InputUnavailable, + RUNTIME_INPUT_NAME, + "run input has not been configured", + ) + }) + } + + pub fn set_event_sink(&mut self, sink: S) -> RuntimeResult<()> + where + S: EventSink + 'static, + { + self.events.set_sink(sink); + Ok(()) + } + + pub fn clear_event_sink(&mut self) { + self.events.clear_sink(); + } + + pub fn emit(&mut self, value: Value) -> RuntimeResult { + self.events.emit(value) + } + + pub fn emitted_events(&self) -> u64 { + self.events.emitted_events() + } + + pub fn event_limits(&self) -> EventLimits { + self.events.limits() + } +} + +impl Default for RuntimeContext { + fn default() -> Self { + Self::with_config(RuntimeContextConfig::default()) + .expect("default runtime context configuration should be valid") + } +} + +/// Parent registration helper for the zero-argument `runtime::input()` host function. +pub fn runtime_input(context: &RuntimeContext) -> VmResult { + context + .input() + .map_err(|error| crate::vm::VmError::HostError(error.to_string())) +} + +/// Parent registration helper for the one-argument `runtime::emit(value)` host function. +pub fn runtime_emit(context: &mut RuntimeContext, value: Value) -> VmResult<()> { + context + .emit(value) + .map(|_| ()) + .map_err(|error| crate::vm::VmError::HostError(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::{RUNTIME_EMIT_NAME, RUNTIME_INPUT_NAME, RuntimeContext}; + + #[test] + fn host_names_are_generic_and_stable() { + assert_eq!(RUNTIME_INPUT_NAME, "runtime::input"); + assert_eq!(RUNTIME_EMIT_NAME, "runtime::emit"); + assert!(std::mem::size_of::() > 0); + } +} diff --git a/src/builtins/runtime/context_host.rs b/src/builtins/runtime/context_host.rs new file mode 100644 index 00000000..d4c32b0f --- /dev/null +++ b/src/builtins/runtime/context_host.rs @@ -0,0 +1,29 @@ +use pd_host_function::pd_host_function; + +use super::AnyValue; +use crate::vm::{Value, Vm, VmResult}; + +/// Returns the embedding-provided input for the current run. +#[pd_host_function(name = "runtime::input")] +fn runtime_input_impl(vm: &mut Vm) -> VmResult { + vm.runtime_input_value() +} + +/// Returns the run-scoped input encoded with the runtime's strict JSON contract. +#[pd_host_function(name = "runtime::input_json")] +fn runtime_input_json_impl(vm: &mut Vm) -> VmResult { + let value = vm.runtime_input_value()?; + super::json::encode_value_to_string(&value) +} + +/// Emits one bounded event without changing the script return value. +#[pd_host_function(name = "runtime::emit")] +fn runtime_emit_impl(vm: &mut Vm, value: AnyValue) -> VmResult<()> { + vm.emit_runtime_event(value) +} + +/// Emits one JSON text event for strict RSS boundary adapters. +#[pd_host_function(name = "runtime::emit_json")] +fn runtime_emit_json_impl(vm: &mut Vm, value: &str) -> VmResult<()> { + vm.emit_runtime_event(Value::string(value)) +} diff --git a/src/builtins/runtime/error.rs b/src/builtins/runtime/error.rs new file mode 100644 index 00000000..506ccf52 --- /dev/null +++ b/src/builtins/runtime/error.rs @@ -0,0 +1,165 @@ +use std::fmt; + +/// Result type used by the 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, + InputUnavailable, + EventSinkUnavailable, + EventPayloadTooLarge, + EventDepthExceeded, + EventSequenceExhausted, + EventSinkRejected, + 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::InputUnavailable => "input_unavailable", + Self::EventSinkUnavailable => "event_sink_unavailable", + Self::EventPayloadTooLarge => "event_payload_too_large", + Self::EventDepthExceeded => "event_depth_exceeded", + Self::EventSequenceExhausted => "event_sequence_exhausted", + Self::EventSinkRejected => "event_sink_rejected", + 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 the generic runtime support modules. +/// +/// The core VM currently exposes `VmError::HostError` as the extension point for host failures. +/// Runtime code keeps the stable category and fields until the parent wiring maps it into that +/// existing VM error variant. +#[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 {} + +#[cfg(test)] +mod tests { + use super::{RuntimeError, RuntimeErrorCode}; + + #[test] + fn structured_error_preserves_code_and_fields() { + let error = RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "runtime::emit", + "event payload exceeds the configured bound", + ) + .with_limit(32) + .with_value(64); + + assert_eq!(error.code(), RuntimeErrorCode::EventPayloadTooLarge); + assert_eq!(error.operation(), "runtime::emit"); + assert_eq!(error.limit(), Some(32)); + assert_eq!(error.value(), Some(64)); + assert!(error.to_string().contains("event_payload_too_large")); + } +} diff --git a/src/builtins/runtime/event.rs b/src/builtins/runtime/event.rs new file mode 100644 index 00000000..cc4a3e94 --- /dev/null +++ b/src/builtins/runtime/event.rs @@ -0,0 +1,345 @@ +use crate::vm::Value; + +use super::error::{RuntimeError, RuntimeErrorCode, RuntimeResult}; + +pub const DEFAULT_MAX_EVENT_PAYLOAD_BYTES: usize = 64 * 1024; +pub const DEFAULT_MAX_EVENT_DEPTH: usize = 64; +pub const DEFAULT_MAX_EVENTS: u64 = 1_024; +pub const DEFAULT_MAX_EVENT_BYTES: usize = 16 * 1024 * 1024; + +/// Bounds applied before an event is handed to an embedding-owned sink. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EventLimits { + max_payload_bytes: usize, + max_depth: usize, + max_events: u64, + max_total_bytes: usize, +} + +impl EventLimits { + pub fn new(max_payload_bytes: usize, max_depth: usize) -> RuntimeResult { + Self::with_budget( + max_payload_bytes, + max_depth, + DEFAULT_MAX_EVENTS, + DEFAULT_MAX_EVENT_BYTES, + ) + } + + pub fn with_budget( + max_payload_bytes: usize, + max_depth: usize, + max_events: u64, + max_total_bytes: usize, + ) -> RuntimeResult { + if max_payload_bytes == 0 || max_depth == 0 || max_events == 0 || max_total_bytes == 0 { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::emit", + "event payload and depth limits must be positive", + )); + } + Ok(Self { + max_payload_bytes, + max_depth, + max_events, + max_total_bytes, + }) + } + + pub const fn max_payload_bytes(self) -> usize { + self.max_payload_bytes + } + + pub const fn max_depth(self) -> usize { + self.max_depth + } + + pub const fn max_events(self) -> u64 { + self.max_events + } + + pub const fn max_total_bytes(self) -> usize { + self.max_total_bytes + } +} + +impl Default for EventLimits { + fn default() -> Self { + Self { + max_payload_bytes: DEFAULT_MAX_EVENT_PAYLOAD_BYTES, + max_depth: DEFAULT_MAX_EVENT_DEPTH, + max_events: DEFAULT_MAX_EVENTS, + max_total_bytes: DEFAULT_MAX_EVENT_BYTES, + } + } +} + +/// An event value whose size and nesting have already been checked. +#[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 }) + } + + pub fn value(&self) -> &Value { + &self.value + } + + pub fn size_bytes(&self) -> usize { + self.size_bytes + } + + pub fn into_value(self) -> Value { + self.value + } +} + +/// Embedding-owned transport hook for bounded runtime events. +pub trait EventSink: Send { + fn emit(&mut self, payload: EventPayload) -> RuntimeResult<()>; +} + +impl EventSink for F +where + F: FnMut(EventPayload) -> RuntimeResult<()> + Send + 'static, +{ + fn emit(&mut self, payload: EventPayload) -> RuntimeResult<()> { + self(payload) + } +} + +/// Validates and forwards generic values without attaching agent or platform semantics. +pub struct EventEmitter { + limits: EventLimits, + sink: Option>, + emitted_events: u64, + emitted_bytes: usize, +} + +#[allow(dead_code)] +impl EventEmitter { + pub fn new(limits: EventLimits) -> Self { + Self { + limits, + sink: None, + emitted_events: 0, + emitted_bytes: 0, + } + } + + pub fn limits(&self) -> EventLimits { + self.limits + } + + pub fn set_sink(&mut self, sink: S) + where + S: EventSink + 'static, + { + self.sink = Some(Box::new(sink)); + } + + pub fn clear_sink(&mut self) { + self.sink = None; + } + + pub fn reset_for_reuse(&mut self) { + self.sink = None; + self.emitted_events = 0; + self.emitted_bytes = 0; + } + + pub fn emitted_events(&self) -> u64 { + self.emitted_events + } + + pub fn emit(&mut self, value: Value) -> RuntimeResult { + let payload = EventPayload::try_new(value, self.limits)?; + if self.emitted_events >= self.limits.max_events { + return Err(RuntimeError::new( + RuntimeErrorCode::EventSequenceExhausted, + "runtime::emit", + "event count exceeds the configured bound", + ) + .with_limit(self.limits.max_events.min(usize::MAX as u64) as usize) + .with_value(self.emitted_events)); + } + let total_bytes = self + .emitted_bytes + .checked_add(payload.size_bytes()) + .ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "runtime::emit", + "cumulative event bytes overflowed", + ) + })?; + if total_bytes > self.limits.max_total_bytes { + return Err(RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "runtime::emit", + "cumulative event bytes exceed the configured bound", + ) + .with_limit(self.limits.max_total_bytes) + .with_value(total_bytes as u64)); + } + let sequence = self.emitted_events.checked_add(1).ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::EventSequenceExhausted, + "runtime::emit", + "event sequence exhausted", + ) + })?; + let sink = self.sink.as_mut().ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::EventSinkUnavailable, + "runtime::emit", + "an event sink has not been configured", + ) + })?; + sink.emit(payload.clone()).map_err(|error| { + RuntimeError::new( + RuntimeErrorCode::EventSinkRejected, + "runtime::emit", + error.to_string(), + ) + })?; + self.emitted_events = sequence; + self.emitted_bytes = total_bytes; + Ok(EventReceipt { + sequence, + payload_bytes: payload.size_bytes(), + }) + } +} + +impl Default for EventEmitter { + fn default() -> Self { + Self::new(EventLimits::default()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EventReceipt { + sequence: u64, + payload_bytes: usize, +} + +#[allow(dead_code)] +impl EventReceipt { + pub const fn sequence(self) -> u64 { + self.sequence + } + + pub const fn payload_bytes(self) -> usize { + self.payload_bytes + } +} + +/// Estimates the bounded representation size used by [`EventPayload`]. +/// +/// The estimate is deliberately independent of serialization formats. It counts scalar tags, +/// container headers, string/byte contents, and recursively contained values. The host transport +/// can apply a stricter byte limit when it serializes the validated 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, + "runtime::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, + "runtime::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, + "runtime::emit", + "event payload size overflowed", + ) + .with_limit(limits.max_payload_bytes) + })?; + if total > limits.max_payload_bytes { + return Err(RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "runtime::emit", + "event payload exceeds the configured byte bound", + ) + .with_limit(limits.max_payload_bytes) + .with_value(total as u64)); + } + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::{EventEmitter, EventLimits, EventPayload}; + use crate::vm::Value; + + #[test] + fn payload_size_and_sequence_are_exposed_after_validation() { + let limits = EventLimits::new(128, 4).expect("limits should be valid"); + let payload = + EventPayload::try_new(Value::string("event"), limits).expect("payload should fit"); + assert!(payload.size_bytes() >= 5); + + let mut emitter = EventEmitter::new(limits); + emitter.set_sink(|_| Ok(())); + let receipt = emitter + .emit(Value::string("event")) + .expect("event should be emitted"); + assert_eq!(receipt.sequence(), 1); + assert_eq!(emitter.emitted_events(), 1); + } +} diff --git a/src/builtins/runtime/http.rs b/src/builtins/runtime/http.rs index fbef6b62..f65f6f71 100644 --- a/src/builtins/runtime/http.rs +++ b/src/builtins/runtime/http.rs @@ -9,6 +9,14 @@ use pd_host_function::pd_host_function; use super::{HostCallResult, Vm, VmMap, VmResult}; #[cfg(feature = "http-client")] +use crate::builtins::runtime::cancellation::{ + CancellationReason, CancellationToken, OperationId, OperationOwner, +}; +#[cfg(feature = "http-client")] +use crate::builtins::runtime::error::{RuntimeError, RuntimeErrorCode}; +#[cfg(feature = "http-client")] +use crate::builtins::runtime::resource::ResourceTypeId; +#[cfg(feature = "http-client")] use crate::vm::Value; use crate::vm::{CallReturn, HostOpId, VmError}; @@ -47,33 +55,29 @@ struct HttpCompletion { } #[cfg(feature = "http-client")] +struct HttpRequestResource { + receiver: futures_channel::oneshot::Receiver, +} + pub(crate) struct HttpState { + #[cfg(feature = "http-client")] config: Option, - pending_ops: - std::collections::HashMap>, - abort_handles: std::collections::HashMap, + pub(crate) max_in_flight: usize, } -#[cfg(not(feature = "http-client"))] -pub(crate) struct HttpState; - impl Default for HttpState { fn default() -> Self { - #[cfg(feature = "http-client")] - { - return Self { - config: None, - pending_ops: std::collections::HashMap::new(), - abort_handles: std::collections::HashMap::new(), - }; + Self { + #[cfg(feature = "http-client")] + config: None, + max_in_flight: crate::builtins::runtime::cancellation::DEFAULT_MAX_PENDING_OPERATIONS, } - - #[cfg(not(feature = "http-client"))] - Self } } impl HttpState { + pub(crate) fn reset_for_reuse(&mut self) {} + pub(crate) fn configure(&mut self, config: HttpConfig) { #[cfg(feature = "http-client")] { @@ -86,7 +90,6 @@ impl HttpState { pub(crate) fn clear_configuration(&mut self) { #[cfg(feature = "http-client")] { - self.cancel_all(); self.config = None; } } @@ -94,104 +97,144 @@ impl HttpState { pub(crate) fn is_configured(&self) -> bool { #[cfg(feature = "http-client")] { - return self.config.is_some(); + self.config.is_some() } #[cfg(not(feature = "http-client"))] false } +} - #[cfg(feature = "http-client")] - fn schedule( - &mut self, - op_id: HostOpId, - config: HttpConfig, - request: HttpRequest, - ) -> VmResult { - let (sender, receiver) = futures_channel::oneshot::channel(); - let (abort_handle, abort_registration) = AbortHandle::new_pair(); - let thread_name = format!("rustscript-http-{op_id}"); - std::thread::Builder::new() - .name(thread_name) - .spawn(move || { - let result = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(runtime) => runtime.block_on(async move { - match Abortable::new(execute_request(&config, &request), abort_registration) - .await - { - Ok(result) => result, - Err(_) => { - Err(VmError::HostError("HTTP request was cancelled".to_string())) - } - } - }), - Err(error) => Err(VmError::HostError(format!( - "failed to create HTTP runtime: {error}" - ))), - }; - let _ = sender.send(HttpCompletion { result }); - }) - .map_err(|error| { - VmError::HostError(format!("failed to start HTTP request: {error}")) - })?; - self.pending_ops.insert(op_id, receiver); - self.abort_handles.insert(op_id, abort_handle); - Ok(op_id) - } - - #[cfg(feature = "http-client")] - fn has_pending_op(&self, op_id: HostOpId) -> bool { - self.pending_ops.contains_key(&op_id) +#[cfg(feature = "http-client")] +fn schedule_request(vm: &mut Vm, config: HttpConfig, request: HttpRequest) -> VmResult { + let max_in_flight = vm.host.http_state.max_in_flight; + if vm + .host + .runtime_operations + .operations_by_owner(OperationOwner::Http) + .len() + >= max_in_flight + { + return Err(VmError::HostError(format!( + "HTTP in-flight request limit of {} has been reached", + max_in_flight + ))); } - #[cfg(feature = "http-client")] - fn cancel_pending_op(&mut self, op_id: HostOpId) { - if let Some(handle) = self.abort_handles.remove(&op_id) { - handle.abort(); + let deadline = std::time::Instant::now() + config.request_timeout; + let (sender, receiver) = futures_channel::oneshot::channel(); + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + let operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Http, + Some(&vm.run_ctx.cancellation), + Some(deadline), + Some(Box::new(move |_| { + abort_handle.abort(); + Ok(()) + })), + ) + .map_err(runtime_host_error)?; + let operation_id = operation.id(); + let op_id = operation_id.raw(); + let token = operation.token(); + let worker_operation = operation.clone(); + let resource = match vm.host.runtime_resources.insert( + ResourceTypeId::HTTP_REQUEST, + HttpRequestResource { receiver }, + ) { + Ok(resource) => resource, + Err(error) => { + let _ = vm + .host + .runtime_operations + .cancel(operation_id, CancellationReason::ResourceClosed); + return Err(runtime_host_error(error)); } - self.pending_ops.remove(&op_id); + }; + operation.set_payload(resource); + + let thread_name = format!("rustscript-http-{op_id}"); + if let Err(error) = std::thread::Builder::new() + .name(thread_name) + .spawn(move || { + let result = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime.block_on(async move { + match Abortable::new( + execute_request(&config, &request, &token, deadline), + abort_registration, + ) + .await + { + Ok(result) => result, + Err(_) => cancellation_error(&token), + } + }), + Err(error) => Err(VmError::HostError(format!( + "failed to create HTTP runtime: {error}" + ))), + }; + match &result { + Ok(_) => { + let _ = worker_operation.complete(); + } + Err(error) => { + let _ = worker_operation.fail( + RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "http::request", + error.to_string(), + ) + .with_value(op_id), + ); + } + } + let _ = sender.send(HttpCompletion { result }); + }) + { + super::cancel_runtime_operation(vm, operation_id, CancellationReason::ResourceClosed); + return Err(VmError::HostError(format!( + "failed to start HTTP request: {error}" + ))); } - #[cfg(feature = "http-client")] - fn cancel_all(&mut self) { - for handle in self.abort_handles.drain().map(|(_, handle)| handle) { - handle.abort(); - } - self.pending_ops.clear(); - } + Ok(op_id) +} - #[cfg(feature = "http-client")] - fn poll_pending_op( - &mut self, - op_id: HostOpId, - cx: &mut Context<'_>, - ) -> Poll> { - use std::pin::Pin; +#[cfg(feature = "http-client")] +fn close_request_resource(vm: &mut Vm, op_id: HostOpId, reason: CancellationReason) { + let Ok(operation_id) = OperationId::from_raw(op_id) else { + return; + }; + let Ok(operation) = vm.host.runtime_operations.get(operation_id) else { + return; + }; + let Some(resource) = operation.payload() else { + return; + }; + let _ = super::close_runtime_resource(vm, resource, reason); +} - let poll_result = match self.pending_ops.get_mut(&op_id) { - Some(receiver) => Pin::new(receiver).poll(cx), - None => { - return Poll::Ready(Err(VmError::HostError(format!("unknown HTTP op {op_id}",)))); - } - }; - match poll_result { - Poll::Pending => Poll::Pending, - Poll::Ready(Ok(completion)) => { - self.pending_ops.remove(&op_id); - self.abort_handles.remove(&op_id); - Poll::Ready(completion.result) - } - Poll::Ready(Err(_)) => { - self.pending_ops.remove(&op_id); - self.abort_handles.remove(&op_id); - Poll::Ready(Err(VmError::HostError(format!( - "HTTP op {op_id} was cancelled", - )))) - } - } - } +#[cfg(feature = "http-client")] +fn runtime_host_error(error: impl std::fmt::Display) -> VmError { + VmError::HostError(error.to_string()) +} + +#[cfg(feature = "http-client")] +fn cancellation_vm_error(token: &CancellationToken) -> VmError { + token + .check() + .map(|()| VmError::HostError("HTTP request was cancelled".to_string())) + .unwrap_or_else(runtime_host_error) +} + +#[cfg(feature = "http-client")] +fn cancellation_error(token: &CancellationToken) -> VmResult { + Err(cancellation_vm_error(token)) } /// Starts an HTTP request under the VM's configured network policy. @@ -206,56 +249,69 @@ pub(super) fn builtin_http_client_request( #[cfg(not(feature = "http-client"))] { let _ = (vm, request); - return Err(VmError::HostError( + Err(VmError::HostError( "HTTP client support is disabled; enable the http-client feature".to_string(), - )); + )) } #[cfg(feature = "http-client")] { let config = vm + .host .http_state .config .clone() .ok_or_else(|| VmError::HostError("HTTP host is not configured".to_string()))?; let request = parse_request(request, &config)?; - let op_id = vm.allocate_host_op_id(); - let op_id = vm.http_state.schedule(op_id, config, request)?; + let op_id = schedule_request(vm, config, request)?; Ok(HostCallResult::Pending(op_id)) } } -#[cfg(feature = "http-client")] -pub(super) fn has_pending_op(vm: &Vm, op_id: HostOpId) -> bool { - vm.http_state.has_pending_op(op_id) -} - -#[cfg(not(feature = "http-client"))] -pub(super) fn has_pending_op(_vm: &Vm, _op_id: HostOpId) -> bool { - false -} - -pub(super) fn cancel_pending_op(vm: &mut Vm, op_id: HostOpId) { - #[cfg(feature = "http-client")] - vm.http_state.cancel_pending_op(op_id); - #[cfg(not(feature = "http-client"))] - let _ = (vm, op_id); -} - -pub(super) fn cancel_all_pending_ops(vm: &mut Vm) { - #[cfg(feature = "http-client")] - vm.http_state.cancel_all(); - #[cfg(not(feature = "http-client"))] - let _ = vm; -} - pub(super) fn poll_pending_op( vm: &mut Vm, op_id: HostOpId, cx: &mut Context<'_>, ) -> Poll> { #[cfg(feature = "http-client")] - return vm.http_state.poll_pending_op(op_id, cx); + { + use std::pin::Pin; + + let operation_id = match OperationId::from_raw(op_id) { + Ok(operation_id) => operation_id, + Err(error) => return Poll::Ready(Err(runtime_host_error(error))), + }; + let operation = match vm.host.runtime_operations.get(operation_id) { + Ok(operation) => operation, + Err(error) => return Poll::Ready(Err(runtime_host_error(error))), + }; + let Some(resource) = operation.payload() else { + return Poll::Ready(Err(VmError::HostError(format!( + "HTTP op {op_id} has no completion payload", + )))); + }; + let poll_result = match vm + .host + .runtime_resources + .get_mut::(resource, ResourceTypeId::HTTP_REQUEST) + { + Ok(request) => Pin::new(&mut request.receiver).poll(cx), + Err(error) => return Poll::Ready(Err(runtime_host_error(error))), + }; + match poll_result { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(completion)) => { + close_request_resource(vm, op_id, CancellationReason::ResourceClosed); + Poll::Ready(completion.result) + } + Poll::Ready(Err(_)) => { + close_request_resource(vm, op_id, CancellationReason::ResourceClosed); + Poll::Ready(Err(VmError::HostError(format!( + "HTTP op {op_id} was cancelled", + )))) + } + } + } #[cfg(not(feature = "http-client"))] { @@ -364,7 +420,7 @@ fn map_string(map: &VmMap, key: &str) -> VmResult { } #[cfg(feature = "http-client")] -fn validate_url(config: &HttpConfig, url: &url::Url) -> VmResult> { +fn validate_url_policy<'a>(config: &HttpConfig, url: &'a url::Url) -> VmResult<(&'a str, u16)> { let scheme = url.scheme().to_ascii_lowercase(); if !config .allowed_schemes @@ -395,16 +451,18 @@ fn validate_url(config: &HttpConfig, url: &url::Url) -> VmResult VmResult> { + let (host, port) = validate_url_policy(config, url)?; if config.allow_private_ips { return Ok(None); } - if let Some(host_ip) = host.parse::().ok() { - if is_restricted_ip(host_ip) { - return Err(VmError::HostError( - "HTTP target resolves to a restricted IP".to_string(), - )); - } + if let Ok(host_ip) = host.parse::() { + validate_resolved_addresses(config, &[std::net::SocketAddr::new(host_ip, port)])?; return Ok(None); } @@ -413,16 +471,63 @@ fn validate_url(config: &HttpConfig, url: &url::Url) -> VmResult>(); + validate_resolved_addresses(config, &addresses)?; + Ok(addresses.first().copied()) +} + +#[cfg(feature = "http-client")] +async fn resolve_url( + config: &HttpConfig, + url: &url::Url, + token: &CancellationToken, + deadline: std::time::Instant, +) -> VmResult> { + token.check().map_err(runtime_host_error)?; + let (host, port) = validate_url_policy(config, url)?; + if let Ok(host_ip) = host.parse::() { + let address = std::net::SocketAddr::new(host_ip, port); + validate_resolved_addresses(config, &[address])?; + return Ok(Some(address)); + } + + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + token.cancel(CancellationReason::Deadline); + return Err(cancellation_vm_error(token)); + } + let addresses = tokio::time::timeout(remaining, tokio::net::lookup_host((host, port))) + .await + .map_err(|_| { + token.cancel(CancellationReason::Deadline); + cancellation_vm_error(token) + })? + .map_err(|error| VmError::HostError(format!("HTTP host resolution failed: {error}")))? + .collect::>(); + token.check().map_err(runtime_host_error)?; + validate_resolved_addresses(config, &addresses)?; + addresses + .first() + .copied() + .map(Some) + .ok_or_else(|| VmError::HostError("HTTP target resolves to a restricted IP".to_string())) +} + +#[cfg(feature = "http-client")] +fn validate_resolved_addresses( + config: &HttpConfig, + addresses: &[std::net::SocketAddr], +) -> VmResult<()> { if addresses.is_empty() - || addresses - .iter() - .any(|address| is_restricted_ip(address.ip())) + || (!config.allow_private_ips + && addresses + .iter() + .any(|address| is_restricted_ip(address.ip()))) { return Err(VmError::HostError( "HTTP target resolves to a restricted IP".to_string(), )); } - Ok(addresses.first().copied()) + Ok(()) } #[cfg(feature = "http-client")] @@ -432,9 +537,10 @@ fn is_restricted_ip(ip: std::net::IpAddr) -> bool { ip.is_loopback() || ip.is_private() || ip.is_link_local() - || ip.is_unspecified() || ip.is_broadcast() + || ip.is_documentation() || ip.is_multicast() + || ip.is_unspecified() } std::net::IpAddr::V6(ip) => { if let Some(mapped) = ip.to_ipv4_mapped() { @@ -450,15 +556,21 @@ fn is_restricted_ip(ip: std::net::IpAddr) -> bool { } #[cfg(feature = "http-client")] -async fn execute_request(config: &HttpConfig, request: &HttpRequest) -> VmResult { - let deadline = std::time::Instant::now() + config.request_timeout; +async fn execute_request( + config: &HttpConfig, + request: &HttpRequest, + token: &CancellationToken, + deadline: std::time::Instant, +) -> VmResult { + token.check().map_err(runtime_host_error)?; let mut method = request.method.clone(); let mut url = request.url.clone(); let mut body = request.body.clone(); let mut headers = request.headers.clone(); for redirect_index in 0..=config.max_redirects { - let resolved_address = validate_url(config, &url)?; + token.check().map_err(runtime_host_error)?; + let resolved_address = resolve_url(config, &url, token, deadline).await?; let host = url .host_str() .ok_or_else(|| VmError::HostError("HTTP URL has no host".to_string()))?; @@ -482,12 +594,24 @@ async fn execute_request(config: &HttpConfig, request: &HttpRequest) -> VmResult } let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { - return Err(VmError::HostError("HTTP request timed out".to_string())); + token.cancel(CancellationReason::Deadline); + return Err(cancellation_vm_error(token)); } let response = tokio::time::timeout(remaining, builder.send()) .await - .map_err(|_| VmError::HostError("HTTP request timed out".to_string()))? - .map_err(|error| VmError::HostError(format!("HTTP request failed: {error}")))?; + .map_err(|_| { + token.cancel(CancellationReason::Deadline); + cancellation_vm_error(token) + })? + .map_err(|error| { + if error.is_timeout() { + token.cancel(CancellationReason::Deadline); + cancellation_vm_error(token) + } else { + VmError::HostError(format!("HTTP request failed: {error}")) + } + })?; + token.check().map_err(runtime_host_error)?; if response.status().is_redirection() { if redirect_index == config.max_redirects { return Err(VmError::HostError( @@ -547,6 +671,7 @@ async fn execute_request(config: &HttpConfig, request: &HttpRequest) -> VmResult .await .map_err(|_| VmError::HostError("HTTP response read timed out".to_string()))? } { + token.check().map_err(runtime_host_error)?; let chunk = chunk.map_err(|error| { VmError::HostError(format!("HTTP response read failed: {error}")) })?; @@ -583,7 +708,12 @@ async fn execute_request(config: &HttpConfig, request: &HttpRequest) -> VmResult mod tests { use super::HttpConfig; #[cfg(feature = "http-client")] - use super::{is_restricted_ip, validate_url}; + use super::{ + CancellationReason, HttpRequest, HttpRequestResource, OperationOwner, ResourceTypeId, + execute_request, is_restricted_ip, schedule_request, validate_url, + }; + #[cfg(feature = "http-client")] + use crate::builtins::runtime::cancellation::OperationId; #[test] fn default_http_policy_denies_all_hosts() { @@ -594,6 +724,107 @@ mod tests { assert!(!config.allow_private_ips); } + #[cfg(feature = "http-client")] + #[test] + fn request_uses_shared_operation_and_resource_lifecycle() { + let mut vm = crate::vm::Vm::new(crate::vm::Program::new(Vec::new(), Vec::new())); + vm.set_http_max_in_flight(1); + let config = HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![1], + allow_private_ips: true, + ..HttpConfig::default() + }; + let request = HttpRequest { + method: reqwest::Method::GET, + url: "http://127.0.0.1:1/".parse().expect("valid URL"), + headers: Vec::new(), + body: None, + }; + + let op_id = schedule_request(&mut vm, config, request).expect("request should schedule"); + let operation_id = OperationId::from_raw(op_id).expect("operation id should be valid"); + assert_eq!( + vm.host + .runtime_operations + .get(operation_id) + .expect("operation should be registered") + .owner(), + OperationOwner::Http + ); + let operation = vm + .host + .runtime_operations + .get(operation_id) + .expect("request should remain registered"); + let resource = operation + .payload() + .expect("operation should reference the request resource"); + assert_eq!(resource.resource_type(), ResourceTypeId::HTTP_REQUEST); + assert!( + vm.host + .runtime_resources + .get::(resource, ResourceTypeId::HTTP_REQUEST) + .is_ok() + ); + + let token = operation.token(); + vm.clear_http_configuration(); + assert_eq!(token.reason(), Some(CancellationReason::Requested)); + assert!( + vm.host + .runtime_resources + .get::(resource, ResourceTypeId::HTTP_REQUEST) + .is_err() + ); + assert!(vm.host.runtime_operations.get(operation_id).is_err()); + } + + #[cfg(feature = "http-client")] + #[test] + fn production_request_timeout_sets_structured_deadline_reason() { + use std::time::{Duration, Instant}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let server = std::thread::spawn(move || { + let (_socket, _) = listener.accept().expect("request should connect"); + std::thread::sleep(Duration::from_millis(100)); + }); + let config = HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![address.port()], + allow_private_ips: true, + connect_timeout: Duration::from_millis(50), + request_timeout: Duration::from_millis(20), + ..HttpConfig::default() + }; + let request = HttpRequest { + method: reqwest::Method::GET, + url: format!("http://{address}/").parse().expect("valid URL"), + headers: Vec::new(), + body: None, + }; + let token = crate::builtins::runtime::cancellation::CancellationToken::root(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + + runtime + .block_on(execute_request( + &config, + &request, + &token, + Instant::now() + config.request_timeout, + )) + .expect_err("hanging server should time out"); + assert_eq!(token.reason(), Some(CancellationReason::Deadline)); + server.join().expect("server should exit"); + } + #[cfg(feature = "http-client")] #[test] fn empty_port_allowlist_rejects_explicit_and_default_ports() { diff --git a/src/builtins/runtime/io.rs b/src/builtins/runtime/io.rs index 51a83662..ee9e56cd 100644 --- a/src/builtins/runtime/io.rs +++ b/src/builtins/runtime/io.rs @@ -1,50 +1,137 @@ -use std::collections::HashMap; use std::fs::OpenOptions; use std::future::Future; use std::io::{Read, Write}; use std::pin::Pin; use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, TryLockError}; use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +#[cfg(unix)] +use std::os::unix::process::CommandExt; use futures_channel::oneshot; use pd_host_function::pd_host_function; use super::HostCallResult; +use super::cancellation::{CancellationReason, OperationId, OperationOwner}; +use super::error::{RuntimeError, RuntimeErrorCode}; +use super::resource::{ResourceHandle, ResourceTypeId}; use crate::vm::{CallReturn, HostOpId, Value, Vm, VmError, VmResult}; -pub(crate) struct IoState { - pub(super) next_handle: i64, - pub(super) handles: HashMap, - pending_ops: HashMap>, +pub(super) enum IoHandle { + File(std::fs::File), + PopenRead { child: Child }, + PopenWrite { child: Child }, +} + +struct IoResource { + handle: Mutex>, + process_id: AtomicU32, } -impl Default for IoState { - fn default() -> Self { +impl IoResource { + fn new(handle: IoHandle) -> Self { + let process_id = match &handle { + IoHandle::PopenRead { child } | IoHandle::PopenWrite { child } => Some(child.id()), + IoHandle::File(_) => None, + }; Self { - next_handle: 1, - handles: HashMap::new(), - pending_ops: HashMap::new(), + handle: Mutex::new(Some(handle)), + process_id: AtomicU32::new(process_id.unwrap_or(0)), + } + } + + fn with_handle_mut(&self, apply: impl FnOnce(&mut IoHandle) -> VmResult) -> VmResult { + let mut handle = self + .handle + .lock() + .map_err(|_| VmError::HostError("io resource lock was poisoned".to_string()))?; + let handle = handle + .as_mut() + .ok_or_else(|| VmError::HostError("io resource is already closing".to_string()))?; + apply(handle) + } + + fn take_handle(&self) -> VmResult { + self.handle + .lock() + .map_err(|_| VmError::HostError("io resource lock was poisoned".to_string()))? + .take() + .ok_or_else(|| VmError::HostError("io resource is already closing".to_string())) + } + + fn close(&self, reason: CancellationReason) -> VmResult<()> { + let process_id = self.process_id.swap(0, Ordering::AcqRel); + let termination_error = if reason != CancellationReason::ResourceClosed && process_id != 0 { + terminate_process_tree(process_id).err() + } else { + None + }; + + let deadline = Instant::now() + Duration::from_millis(500); + loop { + match self.handle.try_lock() { + Ok(mut handle) => { + let close_result = match handle.take() { + Some(handle) => close_io_handle(handle, reason), + None => Ok(()), + }; + return match close_result { + Err(error) => Err(error), + Ok(()) => termination_error.map_or(Ok(()), Err), + }; + } + Err(TryLockError::Poisoned(_)) => { + return Err(VmError::HostError( + "io resource lock was poisoned".to_string(), + )); + } + Err(TryLockError::WouldBlock) if Instant::now() >= deadline => { + let termination_detail = termination_error + .as_ref() + .map(|error| format!("; process termination failed: {error}")) + .unwrap_or_default(); + return Err(VmError::HostError(format!( + "timed out interrupting pending io operation{termination_detail}" + ))); + } + Err(TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(5)), + } } } } -pub(super) enum IoHandle { - File(std::fs::File), - PopenRead { child: Child }, - PopenWrite { child: Child }, +impl Drop for IoResource { + fn drop(&mut self) { + let _ = self.close(CancellationReason::VmReset); + } } struct IoAsyncCompletion { - restored_handle: Option<(i64, IoHandle)>, + opened_handle: Option, + closed_handle: Option, result: VmResult, } -pub(super) fn cancel_pending_op(vm: &mut Vm, op_id: HostOpId) { - vm.io_state.pending_ops.remove(&op_id); +impl IoAsyncCompletion { + fn result(result: VmResult) -> Self { + Self { + opened_handle: None, + closed_handle: None, + result, + } + } } -pub(super) fn has_pending_op(vm: &Vm, op_id: HostOpId) -> bool { - vm.io_state.pending_ops.contains_key(&op_id) +impl Drop for IoAsyncCompletion { + fn drop(&mut self) { + let Some(handle) = self.opened_handle.take() else { + return; + }; + let _ = IoResource::new(handle).close(CancellationReason::VmReset); + } } pub(super) fn poll_builtin_io_op( @@ -52,29 +139,57 @@ pub(super) fn poll_builtin_io_op( op_id: HostOpId, cx: &mut Context<'_>, ) -> Poll> { + let operation_id = match OperationId::from_raw(op_id) { + Ok(operation_id) => operation_id, + Err(error) => return Poll::Ready(Err(runtime_host_error(error))), + }; + let operation = match vm.host.runtime_operations.get(operation_id) { + Ok(operation) => operation, + Err(error) => return Poll::Ready(Err(runtime_host_error(error))), + }; + let Some(callback) = operation.payload() else { + return Poll::Ready(Err(VmError::HostError(format!( + "builtin io op {op_id} has no completion payload", + )))); + }; let poll_result = { - let receiver = match vm.io_state.pending_ops.get_mut(&op_id) { - Some(receiver) => receiver, - None => { - return Poll::Ready(Err(VmError::HostError(format!( - "unknown builtin io op {op_id}", - )))); - } + let receiver = match vm + .host + .runtime_resources + .get_mut::>(callback, ResourceTypeId::CALLBACK) + { + Ok(receiver) => receiver, + Err(error) => return Poll::Ready(Err(runtime_host_error(error))), }; Pin::new(receiver).poll(cx) }; match poll_result { Poll::Pending => Poll::Pending, - Poll::Ready(Ok(completion)) => { - vm.io_state.pending_ops.remove(&op_id); - if let Some((handle_id, handle)) = completion.restored_handle { - vm.io_state.handles.insert(handle_id, handle); + Poll::Ready(Ok(mut completion)) => { + let _ = super::close_runtime_resource(vm, callback, CancellationReason::ResourceClosed); + + if let Some(closed_handle) = completion.closed_handle + && let Err(error) = super::close_runtime_resource( + vm, + closed_handle, + CancellationReason::ResourceClosed, + ) + { + completion.result = Err(runtime_host_error(error)); + } + if let Some(opened_handle) = completion.opened_handle.take() { + let result = insert_io_resource(vm, opened_handle) + .map(|handle| CallReturn::one(handle.as_value())); + completion.result = result; } - Poll::Ready(completion.result) + Poll::Ready(std::mem::replace( + &mut completion.result, + Ok(CallReturn::none()), + )) } Poll::Ready(Err(_)) => { - vm.io_state.pending_ops.remove(&op_id); + let _ = super::close_runtime_resource(vm, callback, CancellationReason::Requested); Poll::Ready(Err(VmError::HostError(format!( "builtin io op {op_id} was cancelled", )))) @@ -82,13 +197,6 @@ pub(super) fn poll_builtin_io_op( } } -pub(super) fn close_all_handles(vm: &mut Vm) { - let handles = std::mem::take(&mut vm.io_state.handles); - for (_, handle) in handles { - let _ = close_io_handle(handle); - } -} - /// Opens a file handle for runtime I/O. #[pd_host_function(name = "io::open")] pub(super) fn builtin_io_open( @@ -96,10 +204,9 @@ pub(super) fn builtin_io_open( path: &str, mode: &str, ) -> VmResult> { - let reserved_id = io_reserve_handle_id(vm); let path = path.to_string(); let mode = mode.to_string(); - let op_id = schedule_io_task(vm, move || { + let op_id = schedule_io_task(vm, None, move || { let mut options = OpenOptions::new(); match mode.as_str() { "r" => { @@ -121,24 +228,21 @@ pub(super) fn builtin_io_open( options.read(true).write(true).create(true).append(true); } other => { - return IoAsyncCompletion { - restored_handle: None, - result: Err(VmError::HostError(format!( - "unsupported io_open mode '{other}', expected r/w/a/r+/w+/a+", - ))), - }; + return IoAsyncCompletion::result(Err(VmError::HostError(format!( + "unsupported io_open mode '{other}', expected r/w/a/r+/w+/a+", + )))); } } match options.open(path) { Ok(file) => IoAsyncCompletion { - restored_handle: Some((reserved_id, IoHandle::File(file))), - result: Ok(CallReturn::one(Value::Int(reserved_id))), - }, - Err(err) => IoAsyncCompletion { - restored_handle: None, - result: Err(VmError::HostError(format!("io_open failed: {err}"))), + opened_handle: Some(IoHandle::File(file)), + closed_handle: None, + result: Ok(CallReturn::none()), }, + Err(err) => { + IoAsyncCompletion::result(Err(VmError::HostError(format!("io_open failed: {err}")))) + } } })?; Ok(HostCallResult::Pending(op_id)) @@ -156,47 +260,36 @@ pub(super) fn builtin_io_popen( "unsupported io_popen mode '{mode}', expected r or w" ))); } - let reserved_id = io_reserve_handle_id(vm); let command = command.to_string(); let mode = mode.to_string(); - let op_id = schedule_io_task(vm, move || { + let op_id = schedule_io_task(vm, None, move || { let child = match spawn_shell_command(command.as_str(), mode.as_str()) { Ok(child) => child, - Err(err) => { - return IoAsyncCompletion { - restored_handle: None, - result: Err(err), - }; - } + Err(err) => return IoAsyncCompletion::result(Err(err)), }; let handle = match mode.as_str() { "r" => { if child.stdout.is_none() { - return IoAsyncCompletion { - restored_handle: None, - result: Err(VmError::HostError( - "io_popen('r') did not provide stdout pipe".to_string(), - )), - }; + return IoAsyncCompletion::result(Err(VmError::HostError( + "io_popen('r') did not provide stdout pipe".to_string(), + ))); } IoHandle::PopenRead { child } } "w" => { if child.stdin.is_none() { - return IoAsyncCompletion { - restored_handle: None, - result: Err(VmError::HostError( - "io_popen('w') did not provide stdin pipe".to_string(), - )), - }; + return IoAsyncCompletion::result(Err(VmError::HostError( + "io_popen('w') did not provide stdin pipe".to_string(), + ))); } IoHandle::PopenWrite { child } } _ => unreachable!("mode validated above"), }; IoAsyncCompletion { - restored_handle: Some((reserved_id, handle)), - result: Ok(CallReturn::one(Value::Int(reserved_id))), + opened_handle: Some(handle), + closed_handle: None, + result: Ok(CallReturn::none()), } })?; Ok(HostCallResult::Pending(op_id)) @@ -205,40 +298,32 @@ pub(super) fn builtin_io_popen( /// Reads all remaining text from an I/O handle. #[pd_host_function(name = "io::read_all")] pub(super) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult> { - let handle = io_take_handle(vm, handle_id)?; - let op_id = schedule_io_task(vm, move || { - let mut handle = handle; - let mut out = String::new(); - let result = match &mut handle { - IoHandle::File(file) => 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 => { - return IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result: Err(VmError::HostError( - "io_read_all popen handle missing stdout".to_string(), - )), - }; - } - }; - stdout + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + let op_id = schedule_io_task(vm, Some(handle), move || { + let result = resource.with_handle_mut(|handle| { + let mut out = String::new(); + match handle { + IoHandle::File(file) => file .read_to_string(&mut out) - .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}"))) - .map(|_| CallReturn::one(Value::string(out))) - } - IoHandle::PopenWrite { .. } => Err(VmError::HostError( - "io_read_all requires a readable handle".to_string(), - )), - }; - IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result, - } + .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?, + IoHandle::PopenRead { child } => child + .stdout + .as_mut() + .ok_or_else(|| { + VmError::HostError("io_read_all popen handle missing stdout".to_string()) + })? + .read_to_string(&mut out) + .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?, + IoHandle::PopenWrite { .. } => { + return Err(VmError::HostError( + "io_read_all requires a readable handle".to_string(), + )); + } + }; + Ok(CallReturn::one(Value::string(out))) + }); + IoAsyncCompletion::result(result) })?; Ok(HostCallResult::Pending(op_id)) } @@ -249,35 +334,26 @@ pub(super) fn builtin_io_read_line( vm: &mut Vm, handle_id: i64, ) -> VmResult> { - let handle = io_take_handle(vm, handle_id)?; - let op_id = schedule_io_task(vm, move || { - let mut handle = handle; - 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 => { - return IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result: Err(VmError::HostError( - "io_read_line popen handle missing stdout".to_string(), - )), - }; - } - }; - read_line_from_reader(stdout).map(|line| CallReturn::one(Value::string(line))) - } - IoHandle::PopenWrite { .. } => Err(VmError::HostError( - "io_read_line requires a readable handle".to_string(), - )), - }; - IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result, - } + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + let op_id = schedule_io_task(vm, Some(handle), move || { + let result = resource.with_handle_mut(|handle| { + let line = match handle { + IoHandle::File(file) => read_line_from_reader(file)?, + IoHandle::PopenRead { child } => { + read_line_from_reader(child.stdout.as_mut().ok_or_else(|| { + VmError::HostError("io_read_line popen handle missing stdout".to_string()) + })?)? + } + IoHandle::PopenWrite { .. } => { + return Err(VmError::HostError( + "io_read_line requires a readable handle".to_string(), + )); + } + }; + Ok(CallReturn::one(Value::string(line))) + }); + IoAsyncCompletion::result(result) })?; Ok(HostCallResult::Pending(op_id)) } @@ -290,39 +366,31 @@ pub(super) fn builtin_io_write( text: &str, ) -> VmResult> { let bytes = text.as_bytes().to_vec(); - let handle = io_take_handle(vm, handle_id)?; - let op_id = schedule_io_task(vm, move || { - let mut handle = handle; - 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 => { - return IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result: Err(VmError::HostError( - "io_write popen handle missing stdin".to_string(), - )), - }; - } - }; - stdin + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + let op_id = schedule_io_task(vm, Some(handle), move || { + let result = resource.with_handle_mut(|handle| { + let written = match 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::PopenRead { .. } => Err(VmError::HostError( - "io_write requires a writable handle".to_string(), - )), - }; - IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result, - } + .map_err(|err| VmError::HostError(format!("io_write failed: {err}")))?, + IoHandle::PopenWrite { child } => child + .stdin + .as_mut() + .ok_or_else(|| { + VmError::HostError("io_write popen handle missing stdin".to_string()) + })? + .write(&bytes) + .map_err(|err| VmError::HostError(format!("io_write failed: {err}")))?, + IoHandle::PopenRead { .. } => { + return Err(VmError::HostError( + "io_write requires a writable handle".to_string(), + )); + } + }; + Ok(CallReturn::one(Value::Int(written as i64))) + }); + IoAsyncCompletion::result(result) })?; Ok(HostCallResult::Pending(op_id)) } @@ -330,37 +398,27 @@ pub(super) fn builtin_io_write( /// Flushes buffered output for an I/O handle. #[pd_host_function(name = "io::flush")] pub(super) fn builtin_io_flush(vm: &mut Vm, handle_id: i64) -> VmResult> { - let handle = io_take_handle(vm, handle_id)?; - let op_id = schedule_io_task(vm, move || { - let mut handle = handle; - let result = match &mut handle { - IoHandle::File(file) => 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 => { - return IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result: Err(VmError::HostError( - "io_flush popen handle missing stdin".to_string(), - )), - }; - } - }; - stdin + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + let op_id = schedule_io_task(vm, Some(handle), move || { + let result = resource.with_handle_mut(|handle| { + match handle { + IoHandle::File(file) => file + .flush() + .map_err(|err| VmError::HostError(format!("io_flush failed: {err}")))?, + IoHandle::PopenWrite { child } => child + .stdin + .as_mut() + .ok_or_else(|| { + VmError::HostError("io_flush popen handle missing stdin".to_string()) + })? .flush() - .map_err(|err| VmError::HostError(format!("io_flush failed: {err}"))) - .map(|_| CallReturn::one(Value::Bool(true))) + .map_err(|err| VmError::HostError(format!("io_flush failed: {err}")))?, + IoHandle::PopenRead { .. } => {} } - IoHandle::PopenRead { .. } => Ok(CallReturn::one(Value::Bool(true))), - }; - IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result, - } + Ok(CallReturn::one(Value::Bool(true))) + }); + IoAsyncCompletion::result(result) })?; Ok(HostCallResult::Pending(op_id)) } @@ -368,10 +426,18 @@ pub(super) fn builtin_io_flush(vm: &mut Vm, handle_id: i64) -> VmResult VmResult> { - let handle = io_take_handle(vm, handle_id)?; - let op_id = schedule_io_task(vm, move || IoAsyncCompletion { - restored_handle: None, - result: close_io_handle(handle).map(|_| CallReturn::one(Value::Bool(true))), + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + let op_id = schedule_io_task(vm, Some(handle), move || { + let result = resource + .take_handle() + .and_then(|handle| close_io_handle(handle, CancellationReason::ResourceClosed)) + .map(|_| CallReturn::one(Value::Bool(true))); + IoAsyncCompletion { + opened_handle: None, + closed_handle: Some(handle), + result, + } })?; Ok(HostCallResult::Pending(op_id)) } @@ -380,11 +446,10 @@ pub(super) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult VmResult> { let path = path.to_string(); - let op_id = schedule_io_task(vm, move || IoAsyncCompletion { - restored_handle: None, - result: Ok(CallReturn::one(Value::Bool( + let op_id = schedule_io_task(vm, None, move || { + IoAsyncCompletion::result(Ok(CallReturn::one(Value::Bool( std::path::Path::new(path.as_str()).exists(), - ))), + )))) })?; Ok(HostCallResult::Pending(op_id)) } @@ -400,6 +465,9 @@ fn spawn_shell_command(command: &str, mode: &str) -> VmResult { cmd }; + #[cfg(unix)] + process.process_group(0); + match mode { "r" => { process.stdout(Stdio::piped()).stdin(Stdio::null()); @@ -415,61 +483,405 @@ fn spawn_shell_command(command: &str, mode: &str) -> VmResult { .map_err(|err| VmError::HostError(format!("io_popen failed: {err}"))) } -fn io_reserve_handle_id(vm: &mut Vm) -> i64 { - let id = vm.io_state.next_handle; - vm.io_state.next_handle = vm.io_state.next_handle.saturating_add(1); - id -} - -fn io_take_handle(vm: &mut Vm, handle_id: i64) -> VmResult { +fn resource_handle(handle_id: i64) -> VmResult { if handle_id <= 0 { return Err(VmError::HostError(format!( "invalid io handle id {handle_id}; expected positive handle id" ))); } - vm.io_state - .handles - .remove(&handle_id) - .ok_or_else(|| VmError::HostError(format!("io handle {handle_id} not found"))) + ResourceHandle::from_value(&Value::Int(handle_id)).map_err(runtime_host_error) +} + +fn io_resource_for_handle(vm: &Vm, handle: ResourceHandle) -> VmResult> { + vm.host + .runtime_resources + .get::>(handle, ResourceTypeId::IO_FILE) + .cloned() + .map_err(runtime_host_error) +} + +fn insert_io_resource(vm: &mut Vm, handle: IoHandle) -> VmResult { + vm.host + .runtime_resources + .insert_with_cleanup( + ResourceTypeId::IO_FILE, + Arc::new(IoResource::new(handle)), + |resource, reason| resource.close(reason).map_err(io_cleanup_error), + ) + .map_err(runtime_host_error) } fn schedule_io_task( vm: &mut Vm, + target_resource: Option, task: impl FnOnce() -> IoAsyncCompletion + Send + 'static, ) -> VmResult { - let op_id = vm.allocate_host_op_id(); + let operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Io, + Some(&vm.run_ctx.cancellation), + None, + None, + ) + .map_err(runtime_host_error)?; + if let Some(target_resource) = target_resource { + operation.set_resource(target_resource); + } + let op_id = operation.id().raw(); + let worker_operation = operation.clone(); + let worker_token = operation.token(); let (sender, receiver) = oneshot::channel(); - std::thread::Builder::new() + let callback = match vm + .host + .runtime_resources + .insert(ResourceTypeId::CALLBACK, receiver) + { + Ok(callback) => callback, + Err(error) => { + let _ = vm + .host + .runtime_operations + .cancel(operation.id(), CancellationReason::Requested); + return Err(runtime_host_error(error)); + } + }; + operation.set_payload(callback); + + if let Err(error) = std::thread::Builder::new() .name("pd-vm-io".to_string()) .spawn(move || { - let completion = task(); + let completion = if let Some(reason) = worker_token.reason() { + IoAsyncCompletion::result(Err(VmError::HostError(format!( + "io operation cancelled: {reason:?}" + )))) + } else { + task() + }; + match &completion.result { + Ok(_) => { + let _ = worker_operation.complete(); + } + Err(error) => { + let _ = worker_operation.fail( + RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "io::operation", + error.to_string(), + ) + .with_value(op_id), + ); + } + } let _ = sender.send(completion); }) - .map_err(|err| VmError::HostError(format!("failed to spawn io task: {err}")))?; - vm.io_state.pending_ops.insert(op_id, receiver); + { + let runtime_error = RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "io::schedule", + format!("failed to spawn io task: {error}"), + ) + .with_value(op_id); + let _ = super::close_runtime_resource(vm, callback, CancellationReason::Requested); + let _ = vm + .host + .runtime_operations + .fail(operation.id(), runtime_error); + return Err(VmError::HostError(format!( + "failed to spawn io task: {error}" + ))); + } + Ok(op_id) } -fn close_io_handle(mut handle: IoHandle) -> VmResult<()> { +fn runtime_host_error(error: impl std::fmt::Display) -> VmError { + VmError::HostError(error.to_string()) +} + +fn io_cleanup_error(error: VmError) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceCleanupFailed, + "io::close", + error.to_string(), + ) +} + +fn close_io_handle(mut handle: IoHandle, reason: CancellationReason) -> 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::PopenRead { child } => wait_for_child(child, reason)?, IoHandle::PopenWrite { child } => { let _ = child.stdin.take(); - child - .wait() - .map_err(|err| VmError::HostError(format!("io_close popen wait failed: {err}")))?; + wait_for_child(child, reason)?; } } Ok(()) } +fn wait_for_child(child: &mut Child, reason: CancellationReason) -> VmResult<()> { + if reason == CancellationReason::ResourceClosed { + child + .wait() + .map_err(|err| VmError::HostError(format!("io_close popen wait failed: {err}")))?; + return Ok(()); + } + + let deadline = Instant::now() + Duration::from_millis(500); + loop { + match child.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) if Instant::now() >= deadline => { + if let Err(kill_error) = child.kill() { + return match child.try_wait() { + Ok(Some(_)) => Ok(()), + Ok(None) => Err(VmError::HostError(format!( + "timed out waiting for cancelled io process; direct child fallback failed: {kill_error}" + ))), + Err(wait_error) => Err(VmError::HostError(format!( + "direct child fallback failed: {kill_error}; child status check failed: {wait_error}" + ))), + }; + } + child.wait().map_err(|error| { + VmError::HostError(format!( + "io_close popen wait after direct child fallback failed: {error}" + )) + })?; + return Ok(()); + } + Ok(None) => std::thread::sleep(Duration::from_millis(5)), + Err(error) => { + return Err(VmError::HostError(format!( + "io_close popen wait failed: {error}" + ))); + } + } + } +} + +#[cfg(unix)] +fn terminate_process_tree(process_id: u32) -> VmResult<()> { + let process_id = libc::pid_t::try_from(process_id).map_err(|_| { + VmError::HostError(format!( + "io_close popen process id {process_id} exceeds the platform pid range" + )) + })?; + let group_result = signal_unix_process(-process_id); + match group_result { + Ok(()) => Ok(()), + Err(error) if error.raw_os_error() == Some(libc::ESRCH) => Ok(()), + Err(group_error) => { + let fallback_result = signal_unix_process(process_id); + let fallback_detail = match fallback_result { + Ok(()) => "direct process fallback succeeded".to_string(), + Err(error) if error.raw_os_error() == Some(libc::ESRCH) => { + "direct process had already exited".to_string() + } + Err(error) => format!("direct process fallback failed: {error}"), + }; + Err(VmError::HostError(format!( + "io_close popen process-group termination failed: {group_error}; {fallback_detail}" + ))) + } + } +} + +#[cfg(unix)] +fn signal_unix_process(process_id: libc::pid_t) -> std::io::Result<()> { + // SAFETY: process_id is either the tracked child pid or its negative process-group id. + if unsafe { libc::kill(process_id, libc::SIGKILL) } == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +fn terminate_process_tree(process_id: u32) -> VmResult<()> { + windows_process_tree::terminate(process_id) +} + +#[cfg(windows)] +mod windows_process_tree { + use std::collections::{HashMap, HashSet}; + use std::ffi::c_void; + use std::io; + use std::mem; + use std::ptr; + + use super::{VmError, VmResult}; + + type Handle = *mut c_void; + + const INVALID_HANDLE_VALUE: Handle = -1_isize as Handle; + const TH32CS_SNAPPROCESS: u32 = 0x0000_0002; + const PROCESS_TERMINATE: u32 = 0x0001; + const ERROR_NO_MORE_FILES: i32 = 18; + const ERROR_INVALID_PARAMETER: i32 = 87; + + #[repr(C)] + struct ProcessEntry32W { + size: u32, + usage_count: u32, + process_id: u32, + default_heap_id: usize, + module_id: u32, + thread_count: u32, + parent_process_id: u32, + base_priority: i32, + flags: u32, + executable: [u16; 260], + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn CreateToolhelp32Snapshot(flags: u32, process_id: u32) -> Handle; + fn Process32FirstW(snapshot: Handle, entry: *mut ProcessEntry32W) -> i32; + fn Process32NextW(snapshot: Handle, entry: *mut ProcessEntry32W) -> i32; + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> Handle; + fn TerminateProcess(process: Handle, exit_code: u32) -> i32; + fn CloseHandle(handle: Handle) -> i32; + } + + pub(super) fn terminate(root_process_id: u32) -> VmResult<()> { + let descendants = match descendant_processes(root_process_id) { + Ok(descendants) => descendants, + Err(snapshot_error) => { + let fallback_detail = match terminate_process(root_process_id) { + Ok(()) => "direct process fallback succeeded".to_string(), + Err(error) => format!("direct process fallback failed: {error}"), + }; + return Err(VmError::HostError(format!( + "io_close popen Windows process-tree snapshot failed: {snapshot_error}; {fallback_detail}" + ))); + } + }; + let mut first_error = None; + for process_id in descendants.into_iter().rev() { + if let Err(error) = terminate_process(process_id) { + first_error.get_or_insert(error); + } + } + if let Err(error) = terminate_process(root_process_id) { + first_error.get_or_insert(error); + } + + match first_error { + Some(error) => Err(VmError::HostError(format!( + "io_close popen Windows process-tree termination failed: {error}" + ))), + None => Ok(()), + } + } + + fn descendant_processes(root_process_id: u32) -> VmResult> { + let entries = snapshot_processes().map_err(|error| { + VmError::HostError(format!( + "io_close popen Windows process snapshot failed: {error}" + )) + })?; + let mut children_by_parent = HashMap::>::new(); + for (process_id, parent_process_id) in entries { + children_by_parent + .entry(parent_process_id) + .or_default() + .push(process_id); + } + + let mut descendants = Vec::new(); + let mut visited = HashSet::new(); + let mut pending = vec![root_process_id]; + visited.insert(root_process_id); + while let Some(parent_process_id) = pending.pop() { + let Some(children) = children_by_parent.get(&parent_process_id) else { + continue; + }; + for &child_process_id in children { + if visited.insert(child_process_id) { + descendants.push(child_process_id); + pending.push(child_process_id); + } + } + } + Ok(descendants) + } + + fn snapshot_processes() -> io::Result> { + // SAFETY: the snapshot API receives fixed constants and initialized storage of the + // documented PROCESSENTRY32W layout. Every acquired handle is closed below. + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + + let mut entry: ProcessEntry32W = mem::zeroed(); + entry.size = mem::size_of::() as u32; + let mut entries = Vec::new(); + if Process32FirstW(snapshot, &mut entry) == 0 { + let error = io::Error::last_os_error(); + let _ = CloseHandle(snapshot); + if error.raw_os_error() == Some(ERROR_NO_MORE_FILES) { + return Ok(entries); + } + return Err(error); + } + + loop { + entries.push((entry.process_id, entry.parent_process_id)); + entry = mem::zeroed(); + entry.size = mem::size_of::() as u32; + if Process32NextW(snapshot, &mut entry) == 0 { + let error = io::Error::last_os_error(); + let close_result = CloseHandle(snapshot); + if error.raw_os_error() != Some(ERROR_NO_MORE_FILES) { + return Err(error); + } + if close_result == 0 { + return Err(io::Error::last_os_error()); + } + return Ok(entries); + } + } + } + } + + fn terminate_process(process_id: u32) -> io::Result<()> { + // SAFETY: OpenProcess returns an owned kernel handle which is closed on every path. + unsafe { + let process = OpenProcess(PROCESS_TERMINATE, 0, process_id); + if process == ptr::null_mut() { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER) { + return Ok(()); + } + return Err(error); + } + let terminate_result = TerminateProcess(process, 1); + let terminate_error = (terminate_result == 0).then(io::Error::last_os_error); + let close_result = CloseHandle(process); + if let Some(error) = terminate_error { + return Err(error); + } + if close_result == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + } +} + +#[cfg(not(any(unix, windows)))] +fn terminate_process_tree(process_id: u32) -> VmResult<()> { + Err(VmError::HostError(format!( + "io_close popen process-tree termination is unsupported for process {process_id}" + ))) +} + fn read_line_from_reader(reader: &mut impl Read) -> VmResult { let mut bytes = Vec::new(); let mut one = [0u8; 1]; diff --git a/src/builtins/runtime/io_wasm.rs b/src/builtins/runtime/io_wasm.rs index 3b2998e2..de2fb436 100644 --- a/src/builtins/runtime/io_wasm.rs +++ b/src/builtins/runtime/io_wasm.rs @@ -5,14 +5,6 @@ use pd_host_function::pd_host_function; use super::HostCallResult; use crate::vm::{CallReturn, HostOpId, Value, Vm, VmError, VmResult}; -pub(crate) struct IoState; - -impl Default for IoState { - fn default() -> Self { - Self - } -} - pub(super) fn poll_builtin_io_op( _vm: &mut Vm, op_id: HostOpId, @@ -23,8 +15,6 @@ pub(super) fn poll_builtin_io_op( )))) } -pub(super) fn close_all_handles(_vm: &mut Vm) {} - /// Opens a file handle for runtime I/O. #[pd_host_function(name = "io::open")] pub(super) fn builtin_io_open( diff --git a/src/builtins/runtime/json.rs b/src/builtins/runtime/json.rs index 504d1602..ab1b3ee8 100644 --- a/src/builtins/runtime/json.rs +++ b/src/builtins/runtime/json.rs @@ -10,6 +10,10 @@ use pd_host_function::pd_host_function; /// Encodes a `Value` into a JSON string. #[pd_host_function(name = "json::encode")] pub(super) fn builtin_json_encode(value: &AnyValue) -> VmResult { + encode_value_to_string(value) +} + +pub(super) fn encode_value_to_string(value: &Value) -> VmResult { let json_value = vm_to_json_value(value)?; serde_json::to_string(&json_value) .map_err(|err| VmError::HostError(format!("json_encode failed: {err}"))) diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index b0361b06..a79cb4fc 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -5,9 +5,29 @@ use std::task::{Context, Poll}; use crate::builtins::BuiltinFunction; use crate::vm::{CallOutcome, CallReturn, HostOpId, Value, Vm, VmResult}; +use self::cancellation::{CancellationReason, OperationId, OperationOwner, OperationState}; +use self::error::{RuntimeError, RuntimeErrorCode}; +use self::resource::ResourceHandle; +#[cfg(feature = "sqlite")] +use self::resource::ResourceTypeId; + +type RuntimeOperationPoller = fn(&mut Vm, HostOpId, &mut Context<'_>) -> Poll>; + +const RUNTIME_OPERATION_POLLERS: &[(OperationOwner, RuntimeOperationPoller)] = &[ + (OperationOwner::Io, io::poll_builtin_io_op), + (OperationOwner::Http, http::poll_pending_op), + #[cfg(feature = "sqlite")] + (OperationOwner::Sqlite, sqlite::poll_pending_op), +]; + mod aot; mod bytes; +pub(crate) mod cancellation; +pub(crate) mod context; +mod context_host; pub(crate) mod core; +pub(crate) mod error; +pub(crate) mod event; mod host; mod http; #[cfg(not(target_arch = "wasm32"))] @@ -20,6 +40,9 @@ mod map_iter; mod math; pub(crate) mod print; pub(crate) mod regex; +pub(crate) mod resource; +#[cfg(feature = "sqlite")] +mod sqlite; mod typed; #[cfg(target_arch = "wasm32")] @@ -27,7 +50,6 @@ use io_wasm as io; pub use http::HttpConfig; pub(crate) use http::HttpState; -pub(crate) use io::IoState; pub use typed::HostCallResult; use typed::{ AnyValue, IntoBuiltinCallOutcome, IntoHostCallOutcome, NumberValue, UnknownValue, VmArray, @@ -127,9 +149,98 @@ 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); - http::cancel_pending_op(vm, op_id); +pub(crate) fn cancel_builtin_io_op_with_reason( + vm: &mut Vm, + op_id: HostOpId, + reason: CancellationReason, +) { + let Ok(op_id) = OperationId::from_raw(op_id) else { + return; + }; + let target_resource = vm + .host + .runtime_operations + .get(op_id) + .ok() + .filter(|operation| operation.owner() == OperationOwner::Io) + .and_then(|operation| operation.resource()); + cancel_runtime_operation(vm, op_id, reason); + if let Some(target_resource) = target_resource { + let _ = close_runtime_resource(vm, target_resource, reason); + } +} + +pub(crate) fn cancel_runtime_operation( + vm: &mut Vm, + op_id: OperationId, + reason: CancellationReason, +) { + let payload = vm + .host + .runtime_operations + .get(op_id) + .ok() + .and_then(|operation| operation.payload()); + let _ = vm.host.runtime_operations.cancel(op_id, reason); + if let Some(payload) = payload { + let _ = close_runtime_resource(vm, payload, reason); + } +} + +fn cancel_runtime_operations( + vm: &mut Vm, + operations: Vec, + reason: CancellationReason, +) { + let operations = operations + .into_iter() + .map(|operation| { + let payload = operation.payload(); + (operation, payload) + }) + .collect::>(); + for (operation, _) in &operations { + operation.token().mark_cancelled(reason); + } + for (operation, _) in &operations { + let _ = vm.host.runtime_operations.cancel(operation.id(), reason); + } + for (_, payload) in operations { + if let Some(payload) = payload { + let _ = close_runtime_resource(vm, payload, reason); + } + } +} + +pub(crate) fn close_runtime_resource( + vm: &mut Vm, + handle: ResourceHandle, + reason: CancellationReason, +) -> error::RuntimeResult { + let operations = vm.host.runtime_operations.operations_for_resource(handle); + cancel_runtime_operations(vm, operations, reason); + vm.host.runtime_resources.close(handle, reason) +} + +#[cfg(feature = "sqlite")] +pub(crate) fn close_resources_by_type( + vm: &mut Vm, + resource_type: ResourceTypeId, + reason: CancellationReason, +) { + let handles = vm.host.runtime_resources.handles_of_type(resource_type); + for handle in handles { + let _ = close_runtime_resource(vm, handle, reason); + } +} + +pub(crate) fn cancel_operations_by_owner( + vm: &mut Vm, + owner: OperationOwner, + reason: CancellationReason, +) { + let operations = vm.host.runtime_operations.operations_by_owner(owner); + cancel_runtime_operations(vm, operations, reason); } pub(crate) fn poll_builtin_io_op( @@ -137,24 +248,63 @@ pub(crate) fn poll_builtin_io_op( op_id: HostOpId, cx: &mut Context<'_>, ) -> Poll> { - if io::has_pending_op(vm, op_id) { - io::poll_builtin_io_op(vm, op_id, cx) - } else { - http::poll_pending_op(vm, op_id, cx) + let operation_id = match OperationId::from_raw(op_id) { + Ok(operation_id) => operation_id, + Err(error) => { + return Poll::Ready(Err(crate::vm::VmError::HostError(error.to_string()))); + } + }; + let operation = match vm.host.runtime_operations.get(operation_id) { + Ok(operation) => operation, + Err(error) => { + return Poll::Ready(Err(crate::vm::VmError::HostError(error.to_string()))); + } + }; + if let Err(error) = operation.token().check() { + let reason = operation + .token() + .reason() + .unwrap_or(CancellationReason::Requested); + cancel_builtin_io_op_with_reason(vm, op_id, reason); + return Poll::Ready(Err(crate::vm::VmError::HostError(error.to_string()))); } -} -pub(crate) fn is_builtin_io_op(vm: &Vm, op_id: HostOpId) -> bool { - #[cfg(not(target_arch = "wasm32"))] - if io::has_pending_op(vm, op_id) { - return true; + let Some((_, poller)) = RUNTIME_OPERATION_POLLERS + .iter() + .find(|(owner, _)| *owner == operation.owner()) + else { + return Poll::Ready(Err(crate::vm::VmError::HostError(format!( + "runtime operation owner {:?} is unavailable in this build", + operation.owner() + )))); + }; + let result = poller(vm, op_id, cx); + + match result { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(values)) => { + let _ = vm.host.runtime_operations.complete(operation_id); + Poll::Ready(Ok(values)) + } + Poll::Ready(Err(error)) => { + if let Some(reason) = operation.token().reason() { + cancel_builtin_io_op_with_reason(vm, op_id, reason); + return Poll::Ready(Err(error)); + } + let runtime_error = RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "runtime::operation", + error.to_string(), + ) + .with_value(op_id); + let _ = vm.host.runtime_operations.fail(operation_id, runtime_error); + Poll::Ready(Err(error)) + } } - http::has_pending_op(vm, op_id) } pub(crate) fn close_all_handles(vm: &mut Vm) { - io::close_all_handles(vm); - http::cancel_all_pending_ops(vm); + vm.host.reset_for_reuse(); } #[cfg(test)] diff --git a/src/builtins/runtime/resource.rs b/src/builtins/runtime/resource.rs new file mode 100644 index 00000000..57b66ea7 --- /dev/null +++ b/src/builtins/runtime/resource.rs @@ -0,0 +1,566 @@ +use std::any::Any; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::vm::Value; + +use super::cancellation::CancellationReason; +use super::error::{RuntimeError, RuntimeErrorCode, RuntimeResult}; + +pub const DEFAULT_MAX_RESOURCES: usize = 1024; + +const HANDLE_TYPE_BITS: u64 = 8; +const HANDLE_GENERATION_BITS: u64 = 17; +const HANDLE_SLOT_BITS: u64 = 18; +const HANDLE_ARENA_BITS: u64 = 63 - HANDLE_TYPE_BITS - HANDLE_GENERATION_BITS - HANDLE_SLOT_BITS; + +const HANDLE_TYPE_SHIFT: u64 = 0; +const HANDLE_GENERATION_SHIFT: u64 = HANDLE_TYPE_BITS; +const HANDLE_SLOT_SHIFT: u64 = HANDLE_GENERATION_SHIFT + HANDLE_GENERATION_BITS; +const HANDLE_ARENA_SHIFT: u64 = HANDLE_SLOT_SHIFT + HANDLE_SLOT_BITS; + +const HANDLE_TYPE_MASK: u64 = (1 << HANDLE_TYPE_BITS) - 1; +const HANDLE_GENERATION_MASK: u64 = (1 << HANDLE_GENERATION_BITS) - 1; +const HANDLE_SLOT_MASK: u64 = (1 << HANDLE_SLOT_BITS) - 1; +const HANDLE_ARENA_MASK: u64 = (1 << HANDLE_ARENA_BITS) - 1; + +/// Process-wide monotonic arena identity source. Arena identities are not +/// recycled, so a handle from a dropped VM cannot resolve in a later VM. +static NEXT_ARENA_ID: AtomicU64 = AtomicU64::new(1); + +/// Stable resource type identity carried by every opaque handle. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ResourceTypeId(u16); + +impl ResourceTypeId { + pub const IO_FILE: Self = Self(1); + #[cfg_attr(not(feature = "http-client"), allow(dead_code))] + pub const HTTP_REQUEST: Self = Self(3); + #[cfg_attr(not(feature = "sqlite"), allow(dead_code))] + pub const SQLITE_CONNECTION: Self = Self(5); + pub const CALLBACK: Self = Self(6); + + pub const fn raw(self) -> u16 { + self.0 + } +} + +/// A positive VM integer identifying one typed resource without exposing it. +/// +/// The token carries arena, slot, generation, and resource-type identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ResourceHandle(u64); + +impl ResourceHandle { + pub fn as_value(self) -> Value { + Value::Int(self.0 as i64) + } + + pub fn from_value(value: &Value) -> RuntimeResult { + let Value::Int(raw) = value else { + return Err(invalid_handle("resource handle must be an integer token")); + }; + if *raw <= 0 { + return Err(invalid_handle("resource handle must be a positive token")); + } + Self::from_encoded(*raw as u64) + } + + pub const fn resource_type(self) -> ResourceTypeId { + ResourceTypeId(((self.0 >> HANDLE_TYPE_SHIFT) & HANDLE_TYPE_MASK) as u16) + } + + const fn arena_id(self) -> u64 { + (self.0 >> HANDLE_ARENA_SHIFT) & HANDLE_ARENA_MASK + } + + const fn slot_identity(self) -> u64 { + (self.0 >> HANDLE_SLOT_SHIFT) & HANDLE_SLOT_MASK + } + + const fn generation(self) -> u64 { + (self.0 >> HANDLE_GENERATION_SHIFT) & HANDLE_GENERATION_MASK + } + + fn slot_index(self) -> RuntimeResult { + usize::try_from(self.slot_identity() - 1) + .map_err(|_| invalid_handle("resource handle slot is out of range")) + } + + fn from_encoded(encoded: u64) -> RuntimeResult { + let handle = Self(encoded); + if encoded == 0 + || encoded > i64::MAX as u64 + || handle.arena_id() == 0 + || handle.slot_identity() == 0 + || handle.generation() == 0 + || handle.resource_type().raw() == 0 + { + return Err(invalid_handle( + "resource handle token has an invalid encoding", + )); + } + Ok(handle) + } + + fn encode( + arena_id: u64, + slot_index: usize, + generation: u64, + resource_type: ResourceTypeId, + ) -> RuntimeResult { + let slot_identity = u64::try_from(slot_index) + .ok() + .and_then(|slot| slot.checked_add(1)) + .ok_or_else(|| invalid_handle("resource slot identity overflowed"))?; + if arena_id == 0 + || arena_id > HANDLE_ARENA_MASK + || slot_identity > HANDLE_SLOT_MASK + || generation == 0 + || generation > HANDLE_GENERATION_MASK + || resource_type.raw() == 0 + || u64::from(resource_type.raw()) > HANDLE_TYPE_MASK + { + return Err(invalid_handle( + "resource handle components are out of range", + )); + } + let encoded = (arena_id << HANDLE_ARENA_SHIFT) + | (slot_identity << HANDLE_SLOT_SHIFT) + | (generation << HANDLE_GENERATION_SHIFT) + | (u64::from(resource_type.raw()) << HANDLE_TYPE_SHIFT); + Self::from_encoded(encoded) + } +} + +type ErasedResource = Box; +type ResourceCleanup = + Box RuntimeResult<()> + Send + 'static>; + +struct ResourceSlot { + generation: u32, + resource_type: ResourceTypeId, + value: Option, + cleanup: Option, +} + +/// VM-local bounded arena for typed opaque host resources. +pub struct ResourceArena { + arena_id: u64, + max_entries: usize, + slots: Vec, + vacant_slots: Vec, + active_entries: usize, +} + +impl ResourceArena { + pub fn with_limit(max_entries: usize) -> RuntimeResult { + if max_entries == 0 || max_entries > HANDLE_SLOT_MASK as usize { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "resource::arena", + format!( + "resource arena capacity must be between 1 and {}", + HANDLE_SLOT_MASK + ), + ) + .with_limit(HANDLE_SLOT_MASK as usize)); + } + let arena_id = NEXT_ARENA_ID + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |arena_id| { + (arena_id <= HANDLE_ARENA_MASK).then_some(arena_id + 1) + }) + .map_err(|_| { + RuntimeError::new( + RuntimeErrorCode::ResourceIdExhausted, + "resource::arena", + "resource arena identity space is exhausted", + ) + })?; + Ok(Self { + arena_id, + max_entries, + slots: Vec::new(), + vacant_slots: Vec::new(), + active_entries: 0, + }) + } + + pub fn insert( + &mut self, + resource_type: ResourceTypeId, + value: T, + ) -> RuntimeResult + where + T: Any + Send + 'static, + { + self.allocate(resource_type, Box::new(value), None) + } + + pub fn insert_with_cleanup( + &mut self, + resource_type: ResourceTypeId, + value: T, + cleanup: F, + ) -> RuntimeResult + where + T: Any + Send + 'static, + F: FnOnce(T, CancellationReason) -> RuntimeResult<()> + Send + 'static, + { + let erased_cleanup: ResourceCleanup = Box::new(move |value, reason| { + let value = value.downcast::().map_err(|_| { + RuntimeError::new( + RuntimeErrorCode::ResourceTypeMismatch, + "resource::cleanup", + "resource cleanup received the wrong concrete type", + ) + })?; + cleanup(*value, reason) + }); + self.allocate(resource_type, Box::new(value), Some(erased_cleanup)) + } + + #[cfg_attr(not(feature = "sqlite"), allow(dead_code))] + pub fn count_type(&self, resource_type: ResourceTypeId) -> usize { + self.slots + .iter() + .filter(|slot| slot.resource_type == resource_type && slot.value.is_some()) + .count() + } + + #[cfg(feature = "sqlite")] + pub fn handles_of_type(&self, resource_type: ResourceTypeId) -> Vec { + self.slots + .iter() + .enumerate() + .filter(|(_, slot)| slot.resource_type == resource_type && slot.value.is_some()) + .filter_map(|(slot_index, slot)| { + ResourceHandle::encode( + self.arena_id, + slot_index, + u64::from(slot.generation), + slot.resource_type, + ) + .ok() + }) + .collect() + } + + pub fn get(&self, handle: ResourceHandle, expected_type: ResourceTypeId) -> RuntimeResult<&T> + where + T: Any + Send + 'static, + { + self.active_slot(handle, expected_type)? + .value + .as_ref() + .and_then(|value| value.downcast_ref::()) + .ok_or_else(|| type_mismatch(handle, expected_type)) + } + + pub fn get_mut( + &mut self, + handle: ResourceHandle, + expected_type: ResourceTypeId, + ) -> RuntimeResult<&mut T> + where + T: Any + Send + 'static, + { + self.active_slot_mut(handle, expected_type)? + .value + .as_mut() + .and_then(|value| value.downcast_mut::()) + .ok_or_else(|| type_mismatch(handle, expected_type)) + } + + pub fn close( + &mut self, + handle: ResourceHandle, + reason: CancellationReason, + ) -> RuntimeResult { + let slot_index = self.validate_handle_identity(handle)?; + let (value, cleanup, reusable) = { + let slot = &mut self.slots[slot_index]; + validate_slot_identity(slot, handle)?; + if slot.resource_type != handle.resource_type() { + return Err(type_mismatch(handle, slot.resource_type)); + } + let Some(value) = slot.value.take() else { + return Ok(CloseStatus::AlreadyClosed); + }; + self.active_entries -= 1; + ( + value, + slot.cleanup.take(), + u64::from(slot.generation) < HANDLE_GENERATION_MASK, + ) + }; + if reusable { + self.vacant_slots.push(slot_index); + } + let result = if let Some(cleanup) = cleanup { + cleanup(value, reason) + } else { + drop(value); + Ok(()) + }; + result.map(|()| CloseStatus::Closed).map_err(|error| { + RuntimeError::new( + RuntimeErrorCode::ResourceCleanupFailed, + "resource::close", + error.to_string(), + ) + .with_value(handle.0) + }) + } + + pub fn close_all(&mut self, reason: CancellationReason) -> RuntimeResult { + let handles = self + .slots + .iter() + .enumerate() + .filter_map(|(slot_index, slot)| { + slot.value.as_ref().map(|_| { + ResourceHandle::encode( + self.arena_id, + slot_index, + u64::from(slot.generation), + slot.resource_type, + ) + .expect("active resource slot must have an encodable handle") + }) + }) + .collect::>(); + let mut closed = 0; + let mut first_error = None; + for handle in handles { + match self.close(handle, reason) { + Ok(CloseStatus::Closed) => closed += 1, + Ok(CloseStatus::AlreadyClosed) => {} + Err(error) => { + first_error.get_or_insert(error); + } + } + } + match first_error { + Some(error) => Err(error), + None => Ok(closed), + } + } + + fn allocate( + &mut self, + resource_type: ResourceTypeId, + value: ErasedResource, + cleanup: Option, + ) -> RuntimeResult { + if resource_type.raw() == 0 || u64::from(resource_type.raw()) > HANDLE_TYPE_MASK { + return Err(RuntimeError::new( + RuntimeErrorCode::ResourceTypeMismatch, + "resource::insert", + "resource type id is outside the handle encoding range", + )); + } + if self.active_entries >= self.max_entries { + return Err(RuntimeError::new( + RuntimeErrorCode::ResourceLimitExceeded, + "resource::insert", + "resource arena capacity has been reached", + ) + .with_limit(self.max_entries)); + } + + let (slot_index, generation) = if let Some(slot_index) = self.vacant_slots.pop() { + let slot = &mut self.slots[slot_index]; + let generation = slot + .generation + .checked_add(1) + .filter(|generation| u64::from(*generation) <= HANDLE_GENERATION_MASK) + .expect("only reusable resource generations enter the vacant list"); + slot.generation = generation; + slot.resource_type = resource_type; + slot.value = Some(value); + slot.cleanup = cleanup; + (slot_index, generation) + } else { + if self.slots.len() >= self.max_entries { + return Err(RuntimeError::new( + RuntimeErrorCode::ResourceIdExhausted, + "resource::insert", + "resource slot generation space is exhausted", + )); + } + let slot_index = self.slots.len(); + let generation = 1; + self.slots.push(ResourceSlot { + generation, + resource_type, + value: Some(value), + cleanup, + }); + (slot_index, generation) + }; + self.active_entries += 1; + ResourceHandle::encode( + self.arena_id, + slot_index, + u64::from(generation), + resource_type, + ) + } + + fn validate_handle_identity(&self, handle: ResourceHandle) -> RuntimeResult { + if handle.arena_id() != self.arena_id { + return Err(wrong_arena(handle)); + } + let slot_index = handle.slot_index()?; + if slot_index >= self.slots.len() { + return Err(stale_handle(handle)); + } + Ok(slot_index) + } + + fn active_slot( + &self, + handle: ResourceHandle, + expected_type: ResourceTypeId, + ) -> RuntimeResult<&ResourceSlot> { + validate_type(handle, expected_type)?; + let slot_index = self.validate_handle_identity(handle)?; + let slot = &self.slots[slot_index]; + validate_slot(slot, handle, expected_type)?; + Ok(slot) + } + + fn active_slot_mut( + &mut self, + handle: ResourceHandle, + expected_type: ResourceTypeId, + ) -> RuntimeResult<&mut ResourceSlot> { + validate_type(handle, expected_type)?; + let slot_index = self.validate_handle_identity(handle)?; + let slot = &mut self.slots[slot_index]; + validate_slot(slot, handle, expected_type)?; + Ok(slot) + } +} + +impl Default for ResourceArena { + fn default() -> Self { + Self::with_limit(DEFAULT_MAX_RESOURCES) + .expect("default resource arena configuration should be valid") + } +} + +impl Drop for ResourceArena { + fn drop(&mut self) { + let _ = self.close_all(CancellationReason::VmReset); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CloseStatus { + Closed, + AlreadyClosed, +} + +fn validate_type(handle: ResourceHandle, expected_type: ResourceTypeId) -> RuntimeResult<()> { + if handle.resource_type() != expected_type { + return Err(type_mismatch(handle, expected_type)); + } + Ok(()) +} + +fn validate_slot_identity(slot: &ResourceSlot, handle: ResourceHandle) -> RuntimeResult<()> { + if u64::from(slot.generation) != handle.generation() { + return Err(stale_handle(handle)); + } + Ok(()) +} + +fn validate_slot( + slot: &ResourceSlot, + handle: ResourceHandle, + expected_type: ResourceTypeId, +) -> RuntimeResult<()> { + validate_slot_identity(slot, handle)?; + if slot.resource_type != expected_type { + return Err(type_mismatch(handle, expected_type)); + } + if slot.value.is_none() { + return Err(already_closed_error(handle)); + } + Ok(()) +} + +fn invalid_handle(message: &'static str) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::InvalidResourceHandle, + "resource::handle", + message, + ) +} + +fn wrong_arena(handle: ResourceHandle) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceHandleWrongTable, + "resource::handle", + "resource handle does not belong to this VM arena", + ) + .with_value(handle.0) +} + +fn stale_handle(handle: ResourceHandle) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceStale, + "resource::handle", + "resource handle refers to a stale slot generation", + ) + .with_value(handle.0) +} + +fn already_closed_error(handle: ResourceHandle) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceAlreadyClosed, + "resource::handle", + "resource is already closed", + ) + .with_value(handle.0) +} + +fn type_mismatch(handle: ResourceHandle, expected: ResourceTypeId) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceTypeMismatch, + "resource::handle", + format!( + "resource type {} does not match expected type {}", + handle.resource_type().raw(), + expected.raw() + ), + ) + .with_value(handle.0) +} + +#[cfg(test)] +mod tests { + use super::{CancellationReason, ResourceArena, ResourceTypeId}; + + #[test] + fn vacant_slot_reuse_increments_the_generation() { + let mut arena = ResourceArena::with_limit(1).expect("arena should be valid"); + let first = arena + .insert(ResourceTypeId::IO_FILE, 1_u8) + .expect("first resource should be inserted"); + assert_eq!( + arena + .close(first, CancellationReason::ResourceClosed) + .expect("first resource should close"), + super::CloseStatus::Closed + ); + + let replacement = arena + .insert(ResourceTypeId::IO_FILE, 2_u8) + .expect("vacant slot should be reused"); + + assert_eq!(replacement.slot_identity(), first.slot_identity()); + assert_eq!(replacement.generation(), first.generation() + 1); + } +} diff --git a/src/builtins/runtime/sqlite.rs b/src/builtins/runtime/sqlite.rs new file mode 100644 index 00000000..05b1a87a --- /dev/null +++ b/src/builtins/runtime/sqlite.rs @@ -0,0 +1,1168 @@ +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex, mpsc}; +use std::task::{Context, Poll, Waker}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use pd_host_function::pd_host_function; +use rusqlite::hooks::{AuthAction, AuthContext, Authorization}; +use rusqlite::limits::Limit; +use rusqlite::types::{Value as SqlValue, ValueRef}; +use rusqlite::{Connection, OpenFlags, TransactionBehavior, params_from_iter}; + +use super::cancellation::{ + CancellationReason, CancellationToken, OperationId, OperationOwner, OperationStatus, +}; +use super::error::{RuntimeError, RuntimeErrorCode}; +use super::resource::{ResourceHandle, ResourceTypeId}; +use super::typed::{VmArrayRef, VmMapRef}; +use super::{HostCallResult, VmMap}; +use crate::vm::{CallReturn, HostOpId, SqliteLimits, Value, Vm, VmError, VmResult}; + +const SQLITE_PROGRESS_STEPS: i32 = 1_000; +const SQLITE_CLOSE_GRACE: Duration = Duration::from_millis(100); + +/// Returns the affected-row count from a SQLite result envelope. +#[pd_host_function(name = "sqlite::rows_affected")] +pub(super) fn builtin_sqlite_rows_affected_impl(value: VmMapRef<'_>) -> VmResult { + match value.get(&Value::string("rows_affected")) { + Some(Value::Int(value)) => Ok(*value), + Some(_) => Err(VmError::TypeMismatch("SQLite rows_affected integer")), + None => Ok(0), + } +} + +/// Returns the truncation flag from a SQLite query result envelope. +#[pd_host_function(name = "sqlite::truncated")] +pub(super) fn builtin_sqlite_truncated_impl(value: VmMapRef<'_>) -> VmResult { + match value.get(&Value::string("truncated")) { + Some(Value::Bool(value)) => Ok(*value), + Some(_) => Err(VmError::TypeMismatch("SQLite truncated boolean")), + None => Ok(false), + } +} + +/// Returns the continuation cursor from a SQLite query result envelope. +#[pd_host_function(name = "sqlite::next_cursor")] +pub(super) fn builtin_sqlite_next_cursor_impl(value: VmMapRef<'_>) -> VmResult { + match value.get(&Value::string("next_cursor")) { + Some(Value::Int(value)) => Ok(*value), + Some(_) => Err(VmError::TypeMismatch("SQLite next_cursor integer")), + None => Ok(0), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OpenMode { + Memory, + ReadOnly, + ReadWrite, + ReadWriteCreate, +} + +struct OpenOptions { + path: String, + mode: OpenMode, + root: Option, + limits: SqliteLimits, + allow_unsafe_sql: bool, +} + +struct ConnectionSlot { + connection: Mutex, + execution: Mutex<()>, + active_operation: Mutex>, + interrupt: Arc, + limits: SqliteLimits, + allow_unsafe_sql: bool, +} + +struct PendingResult { + receiver: mpsc::Receiver>, + worker: Option>, + waker: Arc>>, +} + +fn runtime_error(error: RuntimeError) -> VmError { + VmError::HostError(error.to_string()) +} + +fn operation_id(op_id: HostOpId) -> VmResult { + OperationId::from_raw(op_id).map_err(runtime_error) +} + +fn handle_value(handle: ResourceHandle) -> i64 { + match handle.as_value() { + Value::Int(value) => value, + _ => unreachable!("resource handles are integer values"), + } +} + +fn sqlite_handle(raw: i64) -> VmResult { + let handle = ResourceHandle::from_value(&Value::Int(raw)) + .map_err(|error| VmError::HostError(format!("unknown SQLite database handle: {error}")))?; + if handle.resource_type() != ResourceTypeId::SQLITE_CONNECTION { + return Err(VmError::HostError( + "unknown SQLite database handle (wrong resource type)".to_string(), + )); + } + Ok(handle) +} + +fn lookup_connection(vm: &Vm, raw: i64) -> VmResult<(ResourceHandle, Arc)> { + let handle = sqlite_handle(raw)?; + let slot = vm + .host + .runtime_resources + .get::>(handle, ResourceTypeId::SQLITE_CONNECTION) + .map_err(|error| VmError::HostError(format!("unknown SQLite database: {error}")))?; + Ok((handle, Arc::clone(slot))) +} + +fn map_value<'a>(map: &'a VmMap, key: &str) -> Option<&'a Value> { + map.get(&Value::string(key)) +} + +fn required_string(map: &VmMap, key: &str) -> VmResult { + match map_value(map, key) { + Some(Value::String(value)) if !value.is_empty() => Ok(value.as_ref().clone()), + Some(Value::String(_)) => Err(VmError::HostError(format!( + "SQLite {key} must not be empty" + ))), + Some(_) => Err(VmError::TypeMismatch("SQLite option string")), + None => Err(VmError::HostError(format!("missing SQLite {key}"))), + } +} + +fn optional_string(map: &VmMap, key: &str) -> VmResult> { + match map_value(map, key) { + Some(Value::String(value)) => Ok(Some(value.as_ref().clone())), + Some(Value::Null) | None => Ok(None), + Some(_) => Err(VmError::TypeMismatch("SQLite option string")), + } +} + +fn parse_positive_usize(value: &Value, label: &str) -> VmResult { + let Value::Int(value) = value else { + return Err(VmError::TypeMismatch("SQLite limit integer")); + }; + if *value <= 0 { + return Err(VmError::HostError(format!( + "SQLite {label} must be positive" + ))); + } + usize::try_from(*value).map_err(|_| VmError::HostError(format!("SQLite {label} is too large"))) +} + +fn parse_positive_u64(value: &Value, label: &str) -> VmResult { + let Value::Int(value) = value else { + return Err(VmError::TypeMismatch("SQLite limit integer")); + }; + if *value <= 0 { + return Err(VmError::HostError(format!( + "SQLite {label} must be positive" + ))); + } + u64::try_from(*value).map_err(|_| VmError::HostError(format!("SQLite {label} is too large"))) +} + +fn parse_limits(value: Option<&Value>, ceiling: SqliteLimits) -> VmResult { + let Some(value) = value else { + return Ok(ceiling); + }; + let Value::Map(map) = value else { + return Err(VmError::TypeMismatch("SQLite limits map")); + }; + let mut limits = ceiling; + for (key, value) in map.iter() { + let Value::String(key) = key else { + return Err(VmError::TypeMismatch("SQLite limit name")); + }; + match key.as_str() { + "max_connections" => { + limits.max_connections = + parse_positive_usize(value, key)?.min(ceiling.max_connections) + } + "max_statements" => { + limits.max_statements = + parse_positive_usize(value, key)?.min(ceiling.max_statements) + } + "max_rows" => limits.max_rows = parse_positive_usize(value, key)?.min(ceiling.max_rows), + "max_columns" => { + limits.max_columns = parse_positive_usize(value, key)?.min(ceiling.max_columns) + } + "max_result_bytes" => { + limits.max_result_bytes = + parse_positive_usize(value, key)?.min(ceiling.max_result_bytes) + } + "max_statement_bytes" => { + limits.max_statement_bytes = + parse_positive_usize(value, key)?.min(ceiling.max_statement_bytes) + } + "max_parameters" => { + limits.max_parameters = + parse_positive_usize(value, key)?.min(ceiling.max_parameters) + } + "max_parameter_bytes" => { + limits.max_parameter_bytes = + parse_positive_usize(value, key)?.min(ceiling.max_parameter_bytes) + } + "max_pending_operations" => { + limits.max_pending_operations = + parse_positive_usize(value, key)?.min(ceiling.max_pending_operations) + } + "max_transaction_ms" => { + limits.max_transaction_ms = + parse_positive_u64(value, key)?.min(ceiling.max_transaction_ms) + } + "busy_timeout_ms" => { + limits.busy_timeout_ms = + parse_positive_u64(value, key)?.min(ceiling.busy_timeout_ms) + } + _ => { + return Err(VmError::HostError(format!("unknown SQLite limit {key}"))); + } + } + } + Ok(limits) +} + +fn parse_open_options(vm: &Vm, options: &VmMap) -> VmResult { + let path = required_string(options, "path")?; + let mode = match optional_string(options, "mode")?.as_deref() { + Some("memory") => OpenMode::Memory, + Some("read_only") => OpenMode::ReadOnly, + Some("read_write") => OpenMode::ReadWrite, + Some("read_write_create") | None => OpenMode::ReadWriteCreate, + Some(mode) => { + return Err(VmError::HostError(format!( + "unknown SQLite open mode {mode}" + ))); + } + }; + let configured_root = vm + .host + .sqlite_policy + .database_root + .as_deref() + .map(PathBuf::from); + if let Some(requested_root) = optional_string(options, "root")? { + let requested_root = PathBuf::from(requested_root); + if configured_root.as_ref() != Some(&requested_root) { + return Err(VmError::HostError( + "SQLite root must match the embedding policy".to_string(), + )); + } + } + if mode != OpenMode::Memory && configured_root.is_none() { + return Err(VmError::HostError( + "SQLite database root is not configured".to_string(), + )); + } + let limits = parse_limits(map_value(options, "limits"), vm.host.sqlite_policy.limits)?; + Ok(OpenOptions { + path, + mode, + root: configured_root, + limits, + allow_unsafe_sql: vm.host.sqlite_policy.allow_unsafe_sql, + }) +} + +fn parse_query_limits(value: &VmMap, ceiling: SqliteLimits) -> VmResult { + parse_limits(Some(&Value::Map(Arc::new(value.clone()))), ceiling) +} + +fn validate_relative_path(path: &Path) -> VmResult<()> { + if path.as_os_str().is_empty() || path.is_absolute() { + return Err(VmError::HostError( + "SQLite database path must be a non-empty relative path".to_string(), + )); + } + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(VmError::HostError( + "SQLite database path must stay below its configured root".to_string(), + )); + } + Ok(()) +} + +fn canonical_root(root: &Path) -> VmResult { + if !root.is_absolute() { + return Err(VmError::HostError( + "SQLite database root must be absolute".to_string(), + )); + } + fs::canonicalize(root) + .map_err(|error| VmError::HostError(format!("invalid SQLite database root: {error}"))) +} + +fn resolve_database_path(options: &OpenOptions) -> VmResult> { + if options.mode == OpenMode::Memory { + if options.path != ":memory:" { + return Err(VmError::HostError( + "SQLite memory mode requires path ':memory:'".to_string(), + )); + } + return Ok(None); + } + if options.path == ":memory:" { + return Err(VmError::HostError( + "SQLite ':memory:' requires memory open mode".to_string(), + )); + } + let root = options + .root + .as_deref() + .ok_or_else(|| VmError::HostError("SQLite database root is required".to_string()))?; + let root = canonical_root(root)?; + let relative = Path::new(&options.path); + validate_relative_path(relative)?; + let candidate = root.join(relative); + let canonical = if candidate.exists() { + fs::canonicalize(&candidate) + .map_err(|error| VmError::HostError(format!("invalid SQLite database path: {error}")))? + } else { + if options.mode != OpenMode::ReadWriteCreate { + return Err(VmError::HostError(format!( + "SQLite database does not exist: {}", + candidate.display() + ))); + } + let parent = candidate + .parent() + .ok_or_else(|| VmError::HostError("SQLite database path has no parent".to_string()))?; + let canonical_parent = fs::canonicalize(parent).map_err(|error| { + VmError::HostError(format!("invalid SQLite database parent: {error}")) + })?; + let file_name = candidate.file_name().ok_or_else(|| { + VmError::HostError("SQLite database path has no file name".to_string()) + })?; + canonical_parent.join(file_name) + }; + if !canonical.starts_with(&root) { + return Err(VmError::HostError( + "SQLite database path escapes its configured root".to_string(), + )); + } + Ok(Some(canonical)) +} + +fn sqlite_limit(value: usize, label: &str) -> VmResult { + i32::try_from(value) + .map_err(|_| VmError::HostError(format!("SQLite {label} exceeds engine limits"))) +} + +fn install_connection_limits(connection: &Connection, limits: SqliteLimits) -> VmResult<()> { + let max_value_bytes = limits.max_result_bytes.max(limits.max_parameter_bytes); + connection.set_limit( + Limit::SQLITE_LIMIT_LENGTH, + sqlite_limit(max_value_bytes, "value byte limit")?, + ); + connection.set_limit( + Limit::SQLITE_LIMIT_SQL_LENGTH, + sqlite_limit(limits.max_statement_bytes, "statement byte limit")?, + ); + connection.set_limit( + Limit::SQLITE_LIMIT_COLUMN, + sqlite_limit(limits.max_columns, "column limit")?, + ); + connection.set_limit( + Limit::SQLITE_LIMIT_VARIABLE_NUMBER, + sqlite_limit(limits.max_parameters, "parameter count limit")?, + ); + Ok(()) +} + +fn install_authorizer(connection: &Connection, allow_unsafe_sql: bool) { + connection.authorizer(Some(move |context: AuthContext<'_>| { + if allow_unsafe_sql { + return Authorization::Allow; + } + match context.action { + AuthAction::Attach { .. } + | AuthAction::Detach { .. } + | AuthAction::Pragma { .. } + | AuthAction::CreateVtable { .. } + | AuthAction::DropVtable { .. } + | AuthAction::Unknown { .. } => Authorization::Deny, + AuthAction::Function { function_name } + if function_name.eq_ignore_ascii_case("load_extension") => + { + Authorization::Deny + } + _ => Authorization::Allow, + } + })); +} + +fn open_connection(options: &OpenOptions) -> VmResult { + let path = resolve_database_path(options)?; + let flags = match options.mode { + OpenMode::Memory => OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, + OpenMode::ReadOnly => OpenFlags::SQLITE_OPEN_READ_ONLY, + OpenMode::ReadWrite => OpenFlags::SQLITE_OPEN_READ_WRITE, + OpenMode::ReadWriteCreate => { + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE + } + } | OpenFlags::SQLITE_OPEN_NO_MUTEX; + let connection = match path { + Some(path) => Connection::open_with_flags(path, flags), + None => Connection::open_in_memory_with_flags(flags), + } + .map_err(sqlite_error)?; + connection + .busy_timeout(Duration::from_millis(options.limits.busy_timeout_ms)) + .map_err(sqlite_error)?; + install_connection_limits(&connection, options.limits)?; + install_authorizer(&connection, options.allow_unsafe_sql); + Ok(connection) +} + +fn normalized_sql(sql: &str) -> VmResult { + let bytes = sql.as_bytes(); + let mut out = String::with_capacity(sql.len()); + let mut index = 0; + let mut quote = None; + let mut statement_ended = false; + while index < bytes.len() { + let byte = bytes[index]; + if let Some(active_quote) = quote { + if byte == active_quote { + if index + 1 < bytes.len() && bytes[index + 1] == active_quote { + index += 2; + continue; + } + quote = None; + } + index += 1; + continue; + } + if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + out.push(' '); + index += 1; + continue; + } + if byte == b'-' && index + 1 < bytes.len() && bytes[index + 1] == b'-' { + index += 2; + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + out.push(' '); + continue; + } + if byte == b'/' && index + 1 < bytes.len() && bytes[index + 1] == b'*' { + index += 2; + while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') { + index += 1; + } + if index + 1 >= bytes.len() { + return Err(VmError::HostError( + "SQLite SQL contains an unterminated comment".to_string(), + )); + } + index += 2; + out.push(' '); + continue; + } + if byte == b';' { + statement_ended = true; + index += 1; + continue; + } + if statement_ended && !byte.is_ascii_whitespace() { + return Err(VmError::HostError( + "multiple SQLite statements are not allowed".to_string(), + )); + } + out.push((byte as char).to_ascii_lowercase()); + index += 1; + } + if quote.is_some() { + return Err(VmError::HostError( + "SQLite SQL contains an unterminated quote".to_string(), + )); + } + Ok(out) +} + +fn validate_sql(sql: &str, limits: SqliteLimits, allow_unsafe_sql: bool) -> VmResult<()> { + if sql.is_empty() || sql.len() > limits.max_statement_bytes || sql.as_bytes().contains(&0) { + return Err(VmError::HostError(format!( + "SQLite statement exceeds the configured {} byte limit or is invalid", + limits.max_statement_bytes + ))); + } + let normalized = normalized_sql(sql)?; + if allow_unsafe_sql { + return Ok(()); + } + let first = normalized.split_whitespace().next().unwrap_or_default(); + if matches!( + first, + "attach" + | "detach" + | "pragma" + | "vacuum" + | "begin" + | "commit" + | "rollback" + | "savepoint" + | "release" + ) { + return Err(VmError::HostError(format!( + "SQLite statement {first} is not allowed" + ))); + } + if normalized + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .any(|token| token == "load_extension") + { + return Err(VmError::HostError( + "SQLite extension loading is disabled".to_string(), + )); + } + Ok(()) +} + +fn sqlite_params(values: VmArrayRef<'_>, limits: SqliteLimits) -> VmResult> { + if values.len() > limits.max_parameters { + return Err(VmError::HostError( + "SQLite parameter count exceeds the configured limit".to_string(), + )); + } + let mut bytes = 0usize; + let mut params = Vec::with_capacity(values.len()); + for value in values { + let sql_value = match value { + Value::Null => SqlValue::Null, + Value::Int(value) => SqlValue::Integer(*value), + Value::Float(value) => SqlValue::Real(*value), + Value::String(value) => { + bytes = bytes.saturating_add(value.len()); + SqlValue::Text(value.as_ref().clone()) + } + Value::Bytes(value) => { + bytes = bytes.saturating_add(value.len()); + SqlValue::Blob(value.as_ref().clone()) + } + _ => { + return Err(VmError::HostError( + "SQLite parameters support only null, int, float, string, and bytes" + .to_string(), + )); + } + }; + if bytes > limits.max_parameter_bytes { + return Err(VmError::HostError(format!( + "SQLite parameters exceed the configured {} byte limit", + limits.max_parameter_bytes + ))); + } + params.push(sql_value); + } + Ok(params) +} + +fn cancellation_error(token: &CancellationToken) -> VmError { + let reason = token + .reason() + .unwrap_or(CancellationReason::Requested) + .as_str(); + VmError::HostError(format!("SQLite operation cancelled ({reason})")) +} + +fn with_connection( + slot: &ConnectionSlot, + token: &CancellationToken, + operation: impl FnOnce(&mut Connection) -> Result, +) -> VmResult { + token.check().map_err(runtime_error)?; + let mut connection = slot + .connection + .lock() + .map_err(|_| VmError::HostError("SQLite connection lock is poisoned".to_string()))?; + token.check().map_err(runtime_error)?; + let callback_token = token.clone(); + connection.progress_handler( + SQLITE_PROGRESS_STEPS, + Some(move || callback_token.is_cancelled()), + ); + let result = operation(&mut connection); + connection.progress_handler(0, None:: bool>); + if token.is_cancelled() { + return Err(cancellation_error(token)); + } + result.map_err(sqlite_error) +} + +fn sqlite_error(error: rusqlite::Error) -> VmError { + let code = error + .sqlite_error() + .map(|value| value.extended_code.to_string()) + .unwrap_or_else(|| "non_sqlite".to_string()); + let name = error + .sqlite_error_code() + .map(|value| format!("{value:?}")) + .unwrap_or_else(|| "RusqliteError".to_string()); + VmError::HostError(format!("SQLite error {name} ({code}): {error}")) +} + +fn estimate_value_bytes(value: &Value) -> usize { + match value { + Value::Null => 1, + Value::Int(_) | Value::Float(_) => 8, + Value::Bool(_) => 1, + Value::String(value) => value.len(), + Value::Bytes(value) => value.len(), + Value::Array(values) => values.iter().map(estimate_value_bytes).sum(), + Value::Map(values) => values + .iter() + .map(|(key, value)| { + estimate_value_bytes(key).saturating_add(estimate_value_bytes(value)) + }) + .sum(), + Value::Callable(_) => 8, + } +} + +fn value_from_row(row: &rusqlite::Row<'_>, index: usize) -> Result { + match row.get_ref(index)? { + ValueRef::Null => Ok(Value::Null), + ValueRef::Integer(value) => Ok(Value::Int(value)), + ValueRef::Real(value) => Ok(Value::Float(value)), + ValueRef::Text(value) => match std::str::from_utf8(value) { + Ok(value) => Ok(Value::string(value)), + Err(_) => Ok(Value::bytes(value.to_vec())), + }, + ValueRef::Blob(value) => Ok(Value::bytes(value.to_vec())), + } +} + +fn query_with_connection( + connection: &Connection, + sql: &str, + params: &[SqlValue], + limits: SqliteLimits, +) -> Result { + let mut statement = connection.prepare(sql)?; + let columns = statement + .column_names() + .into_iter() + .map(Value::string) + .collect::>(); + if columns.len() > limits.max_columns { + return Err(rusqlite::Error::InvalidColumnIndex(columns.len())); + } + let column_count = columns.len(); + let mut rows = statement.query(params_from_iter(params.iter()))?; + let mut values = Vec::new(); + let mut result_bytes = columns.iter().map(estimate_value_bytes).sum::(); + let mut truncated = false; + let mut next_cursor = None; + while let Some(row) = rows.next()? { + if values.len() >= limits.max_rows { + truncated = true; + break; + } + let mut cells = Vec::with_capacity(column_count); + let mut row_bytes = 0usize; + for index in 0..column_count { + let value = value_from_row(row, index)?; + row_bytes = row_bytes.saturating_add(estimate_value_bytes(&value)); + cells.push(value); + } + if result_bytes.saturating_add(row_bytes) > limits.max_result_bytes { + truncated = true; + break; + } + if let Some(Value::Int(cursor)) = cells.first() { + next_cursor = Some(*cursor); + } + result_bytes = result_bytes.saturating_add(row_bytes); + values.push(Value::array(cells)); + } + let mut entries = vec![ + (Value::string("columns"), Value::array(columns)), + (Value::string("rows"), Value::array(values)), + (Value::string("truncated"), Value::Bool(truncated)), + ]; + if let Some(next_cursor) = next_cursor { + entries.push((Value::string("next_cursor"), Value::Int(next_cursor))); + } + Ok(VmMap::from_entries(entries)) +} + +fn execute_with_connection( + connection: &Connection, + sql: &str, + params: &[SqlValue], +) -> Result { + let mut statement = connection.prepare(sql)?; + let rows_affected = statement.execute(params_from_iter(params.iter()))?; + drop(statement); + Ok(VmMap::from_entries(vec![ + ( + Value::string("rows_affected"), + Value::Int(i64::try_from(rows_affected).unwrap_or(i64::MAX)), + ), + ( + Value::string("last_insert_rowid"), + Value::Int(connection.last_insert_rowid()), + ), + ])) +} + +fn pending_count_for_resource(vm: &Vm, resource: ResourceHandle) -> usize { + vm.host + .runtime_operations + .operations_for_resource(resource) + .into_iter() + .filter(|operation| operation.owner() == OperationOwner::Sqlite) + .count() +} + +fn schedule_operation( + vm: &mut Vm, + resource: ResourceHandle, + slot: Arc, + operation: impl FnOnce(Arc, CancellationToken) -> VmResult + + Send + + 'static, +) -> VmResult { + if pending_count_for_resource(vm, resource) >= slot.limits.max_pending_operations { + return Err(VmError::HostError(format!( + "SQLite pending operation limit {} reached", + slot.limits.max_pending_operations + ))); + } + let deadline = + Instant::now().checked_add(Duration::from_millis(slot.limits.max_transaction_ms)); + let operation_state = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Sqlite, + Some(&vm.run_ctx.cancellation), + deadline, + None, + ) + .map_err(runtime_error)?; + let id = operation_state.id(); + let token = operation_state.token(); + let cleanup_slot = Arc::clone(&slot); + operation_state + .set_cleanup(Box::new(move |end| { + if matches!(end, super::cancellation::OperationEnd::Cancelled(_)) + && cleanup_slot + .active_operation + .lock() + .expect("SQLite active operation lock should not be poisoned") + .is_some_and(|active| active == id) + { + cleanup_slot.interrupt.interrupt(); + } + Ok(()) + })) + .map_err(runtime_error)?; + let worker_operation = operation_state.clone(); + let (sender, receiver) = mpsc::channel(); + let waker = Arc::new(Mutex::new(None::)); + let worker_waker = Arc::clone(&waker); + let worker = thread::Builder::new() + .name(format!("rustscript-sqlite-{}", id.raw())) + .spawn(move || { + let _execution = slot + .execution + .lock() + .expect("SQLite execution lock should not be poisoned"); + *slot + .active_operation + .lock() + .expect("SQLite active operation lock should not be poisoned") = Some(id); + let result = operation(Arc::clone(&slot), token); + *slot + .active_operation + .lock() + .expect("SQLite active operation lock should not be poisoned") = None; + match &result { + Ok(_) => { + let _ = worker_operation.complete(); + } + Err(error) => { + let _ = worker_operation.fail( + RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "sqlite::operation", + error.to_string(), + ) + .with_value(id.raw()), + ); + } + } + let _ = sender.send(result); + if let Ok(mut waker) = worker_waker.lock() + && let Some(waker) = waker.take() + { + waker.wake(); + } + }) + .map_err(|error| { + let _ = vm + .host + .runtime_operations + .cancel(id, CancellationReason::Requested); + VmError::HostError(format!("failed to start SQLite worker: {error}")) + })?; + let pending = PendingResult { + receiver, + worker: Some(worker), + waker, + }; + let payload = match vm.host.runtime_resources.insert_with_cleanup( + ResourceTypeId::CALLBACK, + pending, + |pending, _reason| { + wait_worker_bounded(pending); + Ok(()) + }, + ) { + Ok(payload) => payload, + Err(error) => { + let _ = vm + .host + .runtime_operations + .cancel(id, CancellationReason::ResourceClosed); + return Err(runtime_error(error)); + } + }; + operation_state.set_resource(resource); + operation_state.set_payload(payload); + Ok(id.raw()) +} + +fn wait_worker_bounded(mut pending: PendingResult) { + let deadline = Instant::now() + SQLITE_CLOSE_GRACE; + if let Some(worker) = pending.worker.take() { + while !worker.is_finished() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(1)); + } + if worker.is_finished() { + let _ = worker.join(); + } + } +} + +#[cfg(test)] +#[allow(dead_code)] +pub(super) fn active_operation_id(vm: &Vm, resource_id: i64) -> Option { + let handle = ResourceHandle::from_value(&Value::Int(resource_id)).ok()?; + let slot = vm + .host + .runtime_resources + .get::>(handle, ResourceTypeId::SQLITE_CONNECTION) + .ok()?; + let active = *slot + .active_operation + .lock() + .expect("SQLite active operation lock should not be poisoned"); + active.map(OperationId::raw) +} + +fn cancel_operation(vm: &mut Vm, id: OperationId, reason: CancellationReason) { + let Ok(operation) = vm.host.runtime_operations.get(id) else { + return; + }; + if operation.owner() != OperationOwner::Sqlite { + return; + } + super::cancel_runtime_operation(vm, id, reason); +} + +pub(super) fn poll_pending_op( + vm: &mut Vm, + op_id: HostOpId, + cx: &mut Context<'_>, +) -> Poll> { + let id = match operation_id(op_id) { + Ok(id) => id, + Err(error) => return Poll::Ready(Err(error)), + }; + let operation = match vm.host.runtime_operations.get(id) { + Ok(operation) if operation.owner() == OperationOwner::Sqlite => operation, + Ok(_) => { + return Poll::Ready(Err(VmError::HostError(format!( + "host operation {op_id} is not owned by SQLite" + )))); + } + Err(error) => return Poll::Ready(Err(runtime_error(error))), + }; + let Some(payload) = operation.payload() else { + return Poll::Ready(Err(VmError::HostError(format!( + "SQLite operation {op_id} has no completion payload" + )))); + }; + if operation.token().is_cancelled() { + let reason = operation + .token() + .reason() + .unwrap_or(CancellationReason::Requested); + let error = cancellation_error(&operation.token()); + cancel_operation(vm, id, reason); + return Poll::Ready(Err(error)); + } + let (received, worker) = { + let pending = match vm + .host + .runtime_resources + .get_mut::(payload, ResourceTypeId::CALLBACK) + { + Ok(pending) => pending, + Err(error) => return Poll::Ready(Err(runtime_error(error))), + }; + if let Ok(mut waker) = pending.waker.lock() { + *waker = Some(cx.waker().clone()); + } + let received = pending.receiver.try_recv(); + let worker = if matches!(received, Err(mpsc::TryRecvError::Empty)) { + None + } else { + pending.worker.take() + }; + (received, worker) + }; + if let Some(worker) = worker { + let _ = worker.join(); + } + match received { + Err(mpsc::TryRecvError::Empty) => { + if operation.token().is_cancelled() { + let reason = operation + .token() + .reason() + .unwrap_or(CancellationReason::Requested); + let error = cancellation_error(&operation.token()); + cancel_operation(vm, id, reason); + Poll::Ready(Err(error)) + } else { + Poll::Pending + } + } + Err(mpsc::TryRecvError::Disconnected) => { + let _ = super::close_runtime_resource(vm, payload, CancellationReason::ResourceClosed); + Poll::Ready(Err(VmError::HostError( + "SQLite worker ended without a result".to_string(), + ))) + } + Ok(result) => { + let _ = super::close_runtime_resource(vm, payload, CancellationReason::ResourceClosed); + match result { + Ok(value) => { + if let OperationStatus::Cancelled(_) = operation.status() { + Poll::Ready(Err(cancellation_error(&operation.token()))) + } else { + Poll::Ready(Ok(value)) + } + } + Err(error) => { + if operation.token().is_cancelled() { + Poll::Ready(Err(cancellation_error(&operation.token()))) + } else { + Poll::Ready(Err(error)) + } + } + } + } + } +} + +/// Opens a SQLite database under the embedding-owned path and limit policy. +#[pd_host_function(name = "sqlite::open")] +pub(super) fn builtin_sqlite_open_impl(vm: &mut Vm, options: VmMapRef<'_>) -> VmResult { + let options = parse_open_options(vm, options)?; + let open_count = vm + .host + .runtime_resources + .count_type(ResourceTypeId::SQLITE_CONNECTION); + if open_count >= options.limits.max_connections { + return Err(VmError::HostError(format!( + "SQLite connection limit {} reached", + options.limits.max_connections + ))); + } + let connection = open_connection(&options)?; + let interrupt = Arc::new(connection.get_interrupt_handle()); + let slot = Arc::new(ConnectionSlot { + connection: Mutex::new(connection), + execution: Mutex::new(()), + active_operation: Mutex::new(None), + interrupt: Arc::clone(&interrupt), + limits: options.limits, + allow_unsafe_sql: options.allow_unsafe_sql, + }); + let cleanup_interrupt = Arc::clone(&interrupt); + let handle = vm + .host + .runtime_resources + .insert_with_cleanup( + ResourceTypeId::SQLITE_CONNECTION, + slot, + move |_slot, _reason| { + cleanup_interrupt.interrupt(); + Ok(()) + }, + ) + .map_err(runtime_error)?; + Ok(handle_value(handle)) +} + +/// Executes one parameterized SQLite statement asynchronously. +#[pd_host_function(name = "sqlite::execute")] +pub(super) fn builtin_sqlite_execute_impl( + vm: &mut Vm, + db_id: i64, + sql: &str, + params: VmArrayRef<'_>, +) -> VmResult> { + let (resource, slot) = lookup_connection(vm, db_id)?; + validate_sql(sql, slot.limits, slot.allow_unsafe_sql)?; + let sql = sql.to_string(); + let params = sqlite_params(params, slot.limits)?; + let op_id = schedule_operation(vm, resource, slot, move |slot, token| { + with_connection(&slot, &token, |connection| { + execute_with_connection(connection, &sql, ¶ms) + }) + .map(|value| CallReturn::one(Value::Map(Arc::new(value)))) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Runs one parameterized SQLite query with row and result-byte bounds. +#[pd_host_function(name = "sqlite::query")] +pub(super) fn builtin_sqlite_query_impl( + vm: &mut Vm, + db_id: i64, + sql: &str, + params: VmArrayRef<'_>, + limits: VmMapRef<'_>, +) -> VmResult> { + let (resource, slot) = lookup_connection(vm, db_id)?; + let query_limits = parse_query_limits(limits, slot.limits)?; + validate_sql(sql, query_limits, slot.allow_unsafe_sql)?; + let sql = sql.to_string(); + let params = sqlite_params(params, slot.limits)?; + let op_id = schedule_operation(vm, resource, slot, move |slot, token| { + with_connection(&slot, &token, |connection| { + query_with_connection(connection, &sql, ¶ms, query_limits) + }) + .map(|value| CallReturn::one(Value::Map(Arc::new(value)))) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +struct TransactionStatement { + sql: String, + params: Vec, + query: bool, + limits: SqliteLimits, +} + +fn parse_transaction_statements( + statements: VmArrayRef<'_>, + limits: SqliteLimits, + allow_unsafe_sql: bool, +) -> VmResult> { + if statements.is_empty() { + return Err(VmError::HostError( + "SQLite transaction requires at least one statement".to_string(), + )); + } + if statements.len() > limits.max_statements { + return Err(VmError::HostError(format!( + "SQLite transaction exceeds the configured {} statement limit", + limits.max_statements + ))); + } + statements + .iter() + .map(|statement| { + let Value::Map(statement) = statement else { + return Err(VmError::TypeMismatch("SQLite transaction statement map")); + }; + let sql = required_string(statement, "sql")?; + validate_sql(&sql, limits, allow_unsafe_sql)?; + let params = match map_value(statement, "params") { + Some(Value::Array(params)) => sqlite_params(params, limits)?, + Some(_) => return Err(VmError::TypeMismatch("SQLite parameter array")), + None => Vec::new(), + }; + let query = match map_value(statement, "query") { + Some(Value::Bool(query)) => *query, + Some(_) => return Err(VmError::TypeMismatch("SQLite query flag")), + None => false, + }; + let statement_limits = match map_value(statement, "limits") { + Some(Value::Map(statement_limits)) => parse_query_limits(statement_limits, limits)?, + Some(_) => return Err(VmError::TypeMismatch("SQLite limits map")), + None => limits, + }; + Ok(TransactionStatement { + sql, + params, + query, + limits: statement_limits, + }) + }) + .collect() +} + +/// Executes ordered statements atomically and returns ordered result envelopes. +#[pd_host_function(name = "sqlite::transaction")] +pub(super) fn builtin_sqlite_transaction_impl( + vm: &mut Vm, + db_id: i64, + statements: VmArrayRef<'_>, +) -> VmResult>> { + let (resource, slot) = lookup_connection(vm, db_id)?; + let statements = parse_transaction_statements(statements, slot.limits, slot.allow_unsafe_sql)?; + let op_id = schedule_operation(vm, resource, slot, move |slot, token| { + with_connection(&slot, &token, |connection| { + let transaction = + connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let mut results = Vec::with_capacity(statements.len()); + for statement in statements { + let value = if statement.query { + query_with_connection( + &transaction, + &statement.sql, + &statement.params, + statement.limits, + )? + } else { + execute_with_connection(&transaction, &statement.sql, &statement.params)? + }; + results.push(Value::Map(Arc::new(value))); + } + transaction.commit()?; + Ok(results) + }) + .map(|values| CallReturn::one(Value::array(values))) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Closes a SQLite resource and cancels operations using it. +#[pd_host_function(name = "sqlite::close")] +pub(super) fn builtin_sqlite_close_impl(vm: &mut Vm, db_id: i64) -> VmResult<()> { + let handle = sqlite_handle(db_id)?; + super::close_runtime_resource(vm, handle, CancellationReason::ResourceClosed) + .map_err(|error| VmError::HostError(format!("unknown SQLite database: {error}")))?; + Ok(()) +} diff --git a/src/bytecode.rs b/src/bytecode.rs index 23d12a71..95551dff 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -80,6 +80,7 @@ pub struct ExportedCallable { #[derive(Debug)] pub struct CallableEnvironment { + #[cfg_attr(not(feature = "runtime"), allow(dead_code))] pub(crate) cells: std::sync::Mutex>, } diff --git a/src/compiler/source_map.rs b/src/compiler/source_map.rs index 38163926..622648a8 100644 --- a/src/compiler/source_map.rs +++ b/src/compiler/source_map.rs @@ -119,7 +119,7 @@ impl SourceMap { } /// Register a source at an explicit id (the semantic module graph's - /// [`SourceId`](crate::compiler::modules::SourceId) space) so spans that + /// `SourceId` space) so spans that /// reference that id resolve to this text. Missing slots are filled with /// empty placeholders; an already-occupied slot keeps its first text. pub fn add_source_at( diff --git a/src/lib.rs b/src/lib.rs index 9c033066..6037cd4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,10 @@ pub fn builtin_call_index(name: &str) -> Option { BuiltinFunction::from_source_name(name).map(|builtin| builtin.call_index()) } +#[cfg(feature = "runtime")] +pub use builtins::runtime::error::{RuntimeError, RuntimeErrorCode, RuntimeResult}; +#[cfg(feature = "runtime")] +pub use builtins::runtime::event::{EventPayload, EventSink}; pub use compiler::diagnostics::{ render_compile_error, render_source_error, render_source_path_error, }; @@ -84,12 +88,15 @@ pub use jit::{ pub use vm::diagnostics::render_vm_error; #[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, - ScriptArgs, ScriptCallback, ScriptResult, StaticHostArgsFunction, StaticHostFunction, - StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, VmYieldReason, + AotArtifactError, CallOutcome, CallReturn, CancellationReason, DEFAULT_MAX_SCRIPT_CALL_DEPTH, + EpochCheckpoint, EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge, + HostBindingPlan, HostFunction, HostFunctionRegistry, HostOpId, HostStackFunction, + IntoScriptValue, QueuedScriptInvocation, ScriptArgs, ScriptCallback, ScriptResult, + StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, + VmResult, VmStatus, VmYieldReason, }; +#[cfg(feature = "sqlite")] +pub use vm::{SqliteLimits, SqlitePolicy}; #[cfg(feature = "runtime")] pub use vmbc::{ DisassembleOptions, ValidationError, WireError, decode_program, disassemble_program, diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index 37e31f83..1f944ed5 100644 --- a/src/vm/aot/artifact.rs +++ b/src/vm/aot/artifact.rs @@ -3,7 +3,8 @@ use std::path::Path; use crate::bytecode::Program; use crate::vm::native::{ - helper_entry_offset, interrupt_helper_entry_offset, selected_codegen_backend, + detect_native_stack_layout, helper_entry_offset, interrupt_helper_entry_offset, + native_stack_layout_fingerprint, selected_codegen_backend, }; use crate::vm::{Vm, VmError}; @@ -12,7 +13,7 @@ use super::compile::CompiledProgram; const MAGIC: [u8; 4] = *b"PAT\0"; const VERSION: u16 = 7; -const ABI_VERSION: u16 = 6; +const ABI_VERSION: u16 = 7; const FLAG_INTERPRETER_BOUNDARY_ONLY: u16 = 1; const SUPPORTED_FLAGS: u16 = FLAG_INTERPRETER_BOUNDARY_ONLY; @@ -108,11 +109,12 @@ impl From for AotArtifactError { impl Vm { pub fn encode_aot_artifact(&mut self) -> Result, AotArtifactError> { - if self.aot_program.is_none() { + if self.engine.aot_program.is_none() { self.compile_aot()?; } let program_hash = self.ensure_program_cache_key(); let aot_program = self + .engine .aot_program .as_ref() .ok_or(AotArtifactError::MissingAotProgram)?; @@ -135,8 +137,8 @@ impl Vm { } else { CompiledProgram::from_code(decoded.code, decoded.resume_ips)? }; - self.aot_program = Some(compiled); - self.aot_exec_count = 0; + self.engine.aot_program = Some(compiled); + self.engine.aot_exec_count = 0; Ok(()) } @@ -159,8 +161,8 @@ impl Vm { } else { CompiledProgram::from_code(decoded.code, decoded.resume_ips)? }; - vm.aot_program = Some(compiled); - vm.aot_exec_count = 0; + vm.engine.aot_program = Some(compiled); + vm.engine.aot_exec_count = 0; Ok(vm) } @@ -201,7 +203,8 @@ fn encode_artifact( write_string("os", std::env::consts::OS, &mut out)?; write_string("backend", selected_codegen_backend(), &mut out)?; - write_u32("vm ip offset", std::mem::offset_of!(Vm, ip), &mut out)?; + let native_layout_fingerprint = native_stack_layout_fingerprint(detect_native_stack_layout()?); + out.extend_from_slice(&native_layout_fingerprint.to_le_bytes()); write_u32( "native helper offset", helper_entry_offset() as usize, @@ -281,10 +284,12 @@ fn decode_artifact( selected_codegen_backend().to_string(), cursor.read_string()?, )?; + let expected_layout_fingerprint = + native_stack_layout_fingerprint(detect_native_stack_layout()?); validate_runtime_field( - "vm ip offset", - std::mem::offset_of!(Vm, ip).to_string(), - cursor.read_u32()?.to_string(), + "native stack layout fingerprint", + format!("{expected_layout_fingerprint:#018x}"), + format!("{:#018x}", cursor.read_u64()?), )?; validate_runtime_field( "native helper offset", @@ -438,15 +443,30 @@ impl<'a> Cursor<'a> { #[cfg(test)] mod tests { use super::*; + use crate::vm::native::{detect_native_stack_layout, native_stack_layout_fingerprint}; use crate::{BytecodeBuilder, Program, Value, ValueType, VmStatus}; + fn native_layout_fingerprint_offset(encoded: &[u8]) -> usize { + let mut offset = 11; + for _ in 0..3 { + let len = u32::from_le_bytes( + encoded[offset..offset + 4] + .try_into() + .expect("runtime string length"), + ) as usize; + offset += 4 + len; + } + offset + } + #[test] fn aot_artifact_preserves_interpreter_boundary_mode() { let mut bc = BytecodeBuilder::new(); bc.ret(); let mut vm = Vm::new(Program::new(Vec::new(), bc.finish())); vm.compile_aot().expect("aot compile should succeed"); - vm.aot_program + vm.engine + .aot_program .as_mut() .expect("compiled program") .interpreter_boundary_only = true; @@ -468,6 +488,7 @@ mod tests { .expect("boundary artifact should load"); assert!( standalone + .engine .aot_program .as_ref() .expect("loaded aot program") @@ -501,6 +522,74 @@ mod tests { )); } + #[test] + fn aot_artifact_decode_rejects_previous_native_layout_abi() { + let mut bc = BytecodeBuilder::new(); + bc.ret(); + let mut vm = Vm::new(Program::new(Vec::new(), bc.finish())); + vm.compile_aot().expect("aot compile should succeed"); + let mut encoded = vm + .encode_aot_artifact() + .expect("artifact encode should succeed"); + encoded[6..8].copy_from_slice(&6_u16.to_le_bytes()); + + assert!(matches!( + vm.load_aot_artifact(&encoded), + Err(AotArtifactError::UnsupportedAbiVersion(6)) + )); + } + + #[test] + fn aot_artifact_records_complete_native_layout_fingerprint() { + let mut bc = BytecodeBuilder::new(); + bc.ret(); + let mut vm = Vm::new(Program::new(Vec::new(), bc.finish())); + vm.compile_aot().expect("aot compile should succeed"); + let encoded = vm + .encode_aot_artifact() + .expect("artifact encode should succeed"); + let fingerprint_offset = native_layout_fingerprint_offset(&encoded); + let stored = u64::from_le_bytes( + encoded[fingerprint_offset..fingerprint_offset + 8] + .try_into() + .expect("native layout fingerprint"), + ); + let expected = native_stack_layout_fingerprint( + detect_native_stack_layout().expect("native layout should be detected"), + ); + + assert_eq!(stored, expected); + vm.load_aot_artifact(&encoded) + .expect("matching native layout should load"); + } + + #[test] + fn aot_artifact_rejects_native_layout_fingerprint_mismatch() { + let mut bc = BytecodeBuilder::new(); + bc.ret(); + let mut vm = Vm::new(Program::new(Vec::new(), bc.finish())); + vm.compile_aot().expect("aot compile should succeed"); + let mut encoded = vm + .encode_aot_artifact() + .expect("artifact encode should succeed"); + let fingerprint_offset = native_layout_fingerprint_offset(&encoded); + let stored = u64::from_le_bytes( + encoded[fingerprint_offset..fingerprint_offset + 8] + .try_into() + .expect("native layout fingerprint"), + ); + encoded[fingerprint_offset..fingerprint_offset + 8] + .copy_from_slice(&stored.wrapping_add(1).to_le_bytes()); + + assert!(matches!( + vm.load_aot_artifact(&encoded), + Err(AotArtifactError::IncompatibleRuntime { + field: "native stack layout fingerprint", + .. + }) + )); + } + #[test] fn aot_artifact_decode_rejects_incompatible_program_hash() { let mut first_bc = BytecodeBuilder::new(); @@ -581,7 +670,7 @@ mod tests { .encode_aot_artifact() .expect("artifact encode should succeed"); assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 7); - assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 6); + assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 7); let mut old_format = encoded.clone(); old_format[4..6].copy_from_slice(&6u16.to_le_bytes()); @@ -590,10 +679,10 @@ mod tests { Err(AotArtifactError::UnsupportedVersion(6)) )); let mut old_abi = encoded.clone(); - old_abi[6..8].copy_from_slice(&5u16.to_le_bytes()); + old_abi[6..8].copy_from_slice(&6u16.to_le_bytes()); assert!(matches!( Vm::new_from_aot_artifact_with_jit_config(&old_abi, JitConfig::default()), - Err(AotArtifactError::UnsupportedAbiVersion(5)) + Err(AotArtifactError::UnsupportedAbiVersion(6)) )); let mut standalone = diff --git a/src/vm/aot/compile.rs b/src/vm/aot/compile.rs index c8896093..96718ba7 100644 --- a/src/vm/aot/compile.rs +++ b/src/vm/aot/compile.rs @@ -566,7 +566,7 @@ fn compile_ssa( let ctx_setup_elapsed = ctx_setup_started.elapsed(); let vm_ip_offset = - i32::try_from(std::mem::offset_of!(Vm, ip)).expect("Vm::ip offset must fit i32"); + i32::try_from(std::mem::offset_of!(Vm, instance.ip)).expect("Vm::ip offset must fit i32"); let code_len_i64 = i64::try_from(program.code.len()) .map_err(|_| AotCompileError::Codegen("program length does not fit i64".to_string()))?; diff --git a/src/vm/aot/runtime.rs b/src/vm/aot/runtime.rs index 13029006..abfcb7e4 100644 --- a/src/vm/aot/runtime.rs +++ b/src/vm/aot/runtime.rs @@ -8,32 +8,33 @@ use crate::vm::{ExecOutcome, Vm, VmError, VmResult}; impl Vm { pub fn compile_aot(&mut self) -> VmResult<()> { - self.aot_program = Some(compile_program(self.program())?); - self.aot_exec_count = 0; + self.engine.aot_program = Some(compile_program(self.program())?); + self.engine.aot_exec_count = 0; Ok(()) } pub fn clear_aot(&mut self) { - self.aot_program = None; - self.aot_exec_count = 0; + self.engine.aot_program = None; + self.engine.aot_exec_count = 0; } pub fn has_aot_program(&self) -> bool { - self.aot_program.is_some() + self.engine.aot_program.is_some() } pub fn aot_exec_count(&self) -> u64 { - self.aot_exec_count + self.engine.aot_exec_count } pub fn aot_resume_ips(&self) -> Option<&[usize]> { - self.aot_program + self.engine + .aot_program .as_ref() .map(|program| program.resume_ips.as_ref()) } pub fn dump_aot_info(&self) -> String { - let Some(program) = self.aot_program.as_ref() else { + let Some(program) = self.engine.aot_program.as_ref() else { return "whole-program aot: disabled\n".to_string(); }; @@ -43,7 +44,10 @@ impl Vm { " native codegen backend: {}\n", selected_codegen_backend() )); - out.push_str(&format!(" aot executions: {}\n", self.aot_exec_count)); + out.push_str(&format!( + " aot executions: {}\n", + self.engine.aot_exec_count + )); out.push_str(&format!(" code_bytes={}\n", program.code.len())); out.push_str(&format!( " lowering={}\n", @@ -58,38 +62,51 @@ impl Vm { } pub(crate) fn execute_aot_entry(&mut self) -> VmResult { - let Some(entry) = self.aot_program.as_ref().map(|program| program.entry) else { + if !self.host.allow_default_host_capabilities { + self.engine.aot_interpreter_boundary_hit = true; + return Ok(ExecOutcome::Continue); + } + let Some(entry) = self + .engine + .aot_program + .as_ref() + .map(|program| program.entry) + else { return Ok(ExecOutcome::Continue); }; clear_bridge_error(); unsafe { crate::vm::native::prepare_for_execution() }; let status = unsafe { entry(self as *mut Vm) }; - self.aot_exec_count = self.aot_exec_count.saturating_add(1); + self.engine.aot_exec_count = self.engine.aot_exec_count.saturating_add(1); match status { STATUS_CONTINUE | STATUS_LINKED_CONTINUE => Ok(ExecOutcome::Continue), STATUS_HALTED => Ok(ExecOutcome::Halted), STATUS_YIELDED => { - self.last_yield_reason = Some(super::super::VmYieldReason::Host); + self.instance.last_yield_reason = Some(super::super::VmYieldReason::Host); Ok(ExecOutcome::Yielded) } STATUS_WAITING => { - let op_id = self.waiting_host_op.map(|op| op.op_id).ok_or_else(|| { - VmError::JitNative( - "aot call bridge reported waiting without a pending op".to_string(), - ) - })?; + let op_id = self + .instance + .waiting_host_op + .map(|op| op.op_id) + .ok_or_else(|| { + VmError::JitNative( + "aot call bridge reported waiting without a pending op".to_string(), + ) + })?; Ok(ExecOutcome::Waiting(op_id)) } - STATUS_OUT_OF_FUEL => match self.interrupt_mode { + STATUS_OUT_OF_FUEL => match self.run_ctx.interrupt_mode { super::super::InterruptMode::Fuel => Err(VmError::OutOfFuel { needed: 1, - remaining: self.fuel_remaining, + remaining: self.run_ctx.fuel_remaining, }), super::super::InterruptMode::Epoch => Err(VmError::EpochDeadlineReached { current: self.current_epoch(), - deadline: self.epoch_deadline, + deadline: self.run_ctx.epoch_deadline, }), super::super::InterruptMode::None => Err(VmError::JitNative( "aot interruption checkpoint fired while interruption was disabled".to_string(), @@ -99,18 +116,18 @@ impl Vm { if let Some(err) = take_bridge_error() { return Err(err); } - if self.ip == self.program.code.len() { + if self.instance.ip == self.program.code.len() { return Err(VmError::BytecodeBounds); } Err(VmError::JitNative(format!( "aot entry reported failure without VmError (ip={} stack_len={} aot={})", - self.ip, - self.stack.len(), + self.instance.ip, + self.instance.stack.len(), self.has_aot_program() ))) } STATUS_TRACE_EXIT => { - self.aot_interpreter_boundary_hit = true; + self.engine.aot_interpreter_boundary_hit = true; Ok(ExecOutcome::Continue) } other => Err(VmError::JitNative(format!( diff --git a/src/vm/engine.rs b/src/vm/engine.rs new file mode 100644 index 00000000..33acefe9 --- /dev/null +++ b/src/vm/engine.rs @@ -0,0 +1,141 @@ +//! Backend engine state. +//! +//! [`Engine`] owns the code-generation backends and their caches: the trace +//! JIT engine, native traces and their counters, the optional AOT program, +//! the regex cache, program-derived decode caches, and code-generation +//! telemetry. It holds no per-run interpreter state and no host bindings, so +//! it can be shared across runs (and, by construction, reused by any number of +//! instances that never share stacks or resources). +//! +//! Native ABI note: the JIT/AOT code generators read a handful of fields by +//! machine offset through `std::mem::offset_of!(Vm, engine.)`. The +//! field set and the offsets are part of the native ABI; see +//! `crate::vm::native::layout`. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::builtins::runtime::regex::RegexCache; +use crate::bytecode::{DecodedInstructionData, Program}; +use crate::vm::aot; +use crate::vm::jit; +use crate::vm::native; + +/// Engine-owned backend configuration, caches, and code-generation telemetry. +/// +/// Thread safety: `Engine` is not shared between threads (`TraceJitEngine` is +/// not `Sync`); one VM facade owns one engine. Clone semantics: `Engine` is +/// intentionally not `Clone` — duplicating it would duplicate native traces +/// and JIT bookkeeping that are keyed to one execution identity. +pub(crate) struct Engine { + pub(crate) jit: jit::TraceJitEngine, + pub(crate) native_traces: Vec>, + pub(crate) native_trace_exec_count: u64, + pub(crate) aot_program: Option, + pub(crate) aot_exec_count: u64, + pub(crate) aot_interpreter_boundary_hit: bool, + pub(crate) jit_native_region_entry_count: u64, + pub(crate) jit_native_region_edge_count: u64, + pub(crate) jit_native_direct_link_count: u64, + pub(crate) jit_native_direct_links_enabled: bool, + pub(crate) jit_native_direct_cross_frame_enabled: bool, + pub(crate) jit_native_active_direct_trace_id: usize, + pub(crate) jit_native_direct_escape_streak: u16, + pub(crate) jit_native_direct_region_fallback: bool, + pub(crate) jit_native_compile_time_ns: u64, + pub(crate) jit_native_region_compile_time_ns: u64, + pub(crate) jit_trace_exit_count: u64, + pub(crate) jit_native_loop_back_count: u64, + pub(crate) jit_native_link_handoff_count: u64, + pub(crate) jit_native_link_dispatch_depth: u32, + pub(crate) jit_helper_fallback_count: u64, + pub(crate) jit_native_bridge_stats_enabled: bool, + pub(crate) jit_native_bridge_counts: HashMap<&'static str, u64>, + pub(crate) program_cache_key: u64, + pub(crate) program_cache_key_ready: bool, + pub(crate) regex_cache: RegexCache, + pub(crate) decoded_instruction_data: Arc, + pub(crate) operand_type_hints: Option>, + // Native ABI mirrors: the JIT/AOT code generators load these addresses by + // field offset from the `Vm` facade. They are derived from the program and + // from static helper entry points, and are documented as load-bearing for + // `crate::vm::native`. + pub(crate) program_constants_ptr: usize, + #[allow(dead_code)] + pub(crate) program_constants_len: usize, + #[allow(dead_code)] + pub(crate) native_helper_fn: usize, + #[allow(dead_code)] + pub(crate) native_interrupt_helper_fn: usize, +} + +impl Engine { + /// Builds an engine for one program and JIT configuration. + pub(crate) fn new(jit_config: jit::JitConfig, program: &Program) -> Self { + Self { + jit: jit::TraceJitEngine::new(jit_config), + native_traces: Vec::new(), + native_trace_exec_count: 0, + aot_program: None, + aot_exec_count: 0, + aot_interpreter_boundary_hit: false, + jit_native_region_entry_count: 0, + jit_native_region_edge_count: 0, + jit_native_direct_link_count: 0, + jit_native_direct_links_enabled: true, + jit_native_direct_cross_frame_enabled: false, + jit_native_active_direct_trace_id: usize::MAX, + jit_native_direct_escape_streak: 0, + jit_native_direct_region_fallback: false, + jit_native_compile_time_ns: 0, + jit_native_region_compile_time_ns: 0, + jit_trace_exit_count: 0, + jit_native_loop_back_count: 0, + jit_native_link_handoff_count: 0, + jit_native_link_dispatch_depth: 0, + jit_helper_fallback_count: 0, + jit_native_bridge_stats_enabled: false, + jit_native_bridge_counts: HashMap::new(), + program_cache_key: 0, + program_cache_key_ready: false, + regex_cache: RegexCache::default(), + decoded_instruction_data: program.shared_decoded_instruction_data(), + operand_type_hints: program.shared_operand_type_hints(), + program_constants_ptr: program.constants.as_ptr() as usize, + program_constants_len: program.constants.len(), + native_helper_fn: native::helper_entry_address(), + native_interrupt_helper_fn: native::interrupt_helper_entry_address(), + } + } + + /// Returns the program cache key, computing and caching it on first use. + /// The key identifies the program for backend cache lookups; it is stable + /// for the lifetime of the engine (the program is immutable). + pub(crate) fn ensure_program_cache_key(&mut self, program: &Program) -> u64 { + if !self.program_cache_key_ready { + self.program_cache_key = super::compute_program_cache_key(program); + self.program_cache_key_ready = true; + } + self.program_cache_key + } + + /// Rewinds run-scoped backend state between runs while retaining compiled + /// artifacts: hot-entry bookkeeping and call-site profiles are cleared, + /// and the AOT boundary flag is recomputed from the compiled program. + pub(crate) fn reset_runtime_state(&mut self, program: &Program) { + self.aot_interpreter_boundary_hit = self + .aot_program + .as_ref() + .is_some_and(|compiled| compiled.interpreter_boundary_only); + self.jit.reset_runtime_backoff(); + self.jit.clear_call_site_profiles(); + let _ = program; + } + + /// Invalidates code-generation caches that may reference run-scoped + /// behavior (used when drop-contract event accounting is toggled). + pub(crate) fn invalidate_codegen_caches(&mut self) { + self.native_traces.clear(); + self.native_trace_exec_count = 0; + } +} diff --git a/src/vm/epoch.rs b/src/vm/epoch.rs index 178a1202..0c8b2d93 100644 --- a/src/vm/epoch.rs +++ b/src/vm/epoch.rs @@ -57,56 +57,40 @@ impl EpochHandle { impl Vm { #[inline(always)] pub(in crate::vm) fn charge_epoch_tick(&mut self) -> VmResult<()> { - if !self.epoch_interruption_enabled() { - return Ok(()); - } - if self.fuel_ops_until_check > 1 { - self.fuel_ops_until_check -= 1; - return Ok(()); - } - - let current = self.current_epoch(); - if current >= self.epoch_deadline { - return Err(VmError::EpochDeadlineReached { - current, - deadline: self.epoch_deadline, - }); - } - self.fuel_ops_until_check = self.fuel_check_interval; - Ok(()) + self.run_ctx.charge_epoch_tick() } #[inline(always)] pub(super) fn mark_interrupt_yield(&mut self, reason: VmYieldReason) { - self.last_yield_reason = Some(reason); + self.instance.last_yield_reason = Some(reason); if matches!(reason, VmYieldReason::Epoch) { - self.epoch_rearm_pending = true; + self.run_ctx.epoch_rearm_pending = true; } } #[inline(always)] pub(super) fn rearm_epoch_after_yield_if_needed(&mut self) { - if !self.epoch_rearm_pending { + if !self.run_ctx.epoch_rearm_pending { return; } if !self.epoch_interruption_enabled() { - self.epoch_rearm_pending = false; + self.run_ctx.epoch_rearm_pending = false; return; } - self.epoch_deadline = self + self.run_ctx.epoch_deadline = self .current_epoch() - .saturating_add(self.epoch_deadline_delta); - self.epoch_rearm_pending = false; + .saturating_add(self.run_ctx.epoch_deadline_delta); + self.run_ctx.epoch_rearm_pending = false; self.reset_interrupt_countdown(); } pub(super) fn clear_epoch_deadline_internal(&mut self) { if self.epoch_interruption_enabled() { - self.interrupt_mode = InterruptMode::None; + self.run_ctx.interrupt_mode = InterruptMode::None; } - self.epoch_deadline = 0; - self.epoch_deadline_delta = 0; - self.epoch_rearm_pending = false; + self.run_ctx.epoch_deadline = 0; + self.run_ctx.epoch_deadline_delta = 0; + self.run_ctx.epoch_rearm_pending = false; self.reset_interrupt_countdown(); } @@ -118,29 +102,29 @@ impl Vm { } pub fn epoch_handle(&self) -> EpochHandle { - self.epoch_handle.clone() + self.run_ctx.epoch_handle.clone() } pub fn current_epoch(&self) -> u64 { - self.epoch_handle.current() + self.run_ctx.epoch_handle.current() } pub fn increment_epoch(&self) -> u64 { - self.epoch_handle.increment() + self.run_ctx.epoch_handle.increment() } pub fn increment_epoch_by(&self, delta: u64) -> u64 { - self.epoch_handle.increment_by(delta) + self.run_ctx.epoch_handle.increment_by(delta) } pub fn set_epoch_deadline(&mut self, ticks_beyond_current: u64) -> VmResult<()> { if self.fuel_metering_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Epoch)); } - self.interrupt_mode = InterruptMode::Epoch; - self.epoch_deadline = self.current_epoch().saturating_add(ticks_beyond_current); - self.epoch_deadline_delta = ticks_beyond_current; - self.epoch_rearm_pending = false; + self.run_ctx.interrupt_mode = InterruptMode::Epoch; + self.run_ctx.epoch_deadline = self.current_epoch().saturating_add(ticks_beyond_current); + self.run_ctx.epoch_deadline_delta = ticks_beyond_current; + self.run_ctx.epoch_rearm_pending = false; self.reset_interrupt_countdown(); Ok(()) } @@ -151,12 +135,12 @@ impl Vm { pub fn epoch_deadline(&self) -> Option { self.epoch_interruption_enabled() - .then_some(self.epoch_deadline) + .then_some(self.run_ctx.epoch_deadline) } pub fn epoch_deadline_delta(&self) -> Option { self.epoch_interruption_enabled() - .then_some(self.epoch_deadline_delta) + .then_some(self.run_ctx.epoch_deadline_delta) } pub fn set_epoch_check_interval(&mut self, interval: u32) -> VmResult<()> { @@ -166,7 +150,7 @@ impl Vm { if self.fuel_metering_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Epoch)); } - self.fuel_check_interval = interval; + self.run_ctx.fuel_check_interval = interval; self.reset_interrupt_countdown(); Ok(()) } @@ -179,31 +163,31 @@ impl Vm { EpochCheckpoint { deadline: self .epoch_interruption_enabled() - .then_some(self.epoch_deadline), - deadline_delta: self.epoch_deadline_delta, - rearm_pending: self.epoch_rearm_pending, + .then_some(self.run_ctx.epoch_deadline), + deadline_delta: self.run_ctx.epoch_deadline_delta, + rearm_pending: self.run_ctx.epoch_rearm_pending, check_interval: self.epoch_check_interval(), - ops_until_check: self.fuel_ops_until_check, + ops_until_check: self.run_ctx.fuel_ops_until_check, } } pub fn restore_epoch(&mut self, checkpoint: EpochCheckpoint) { self.clear_fuel_internal(); - self.interrupt_mode = if checkpoint.deadline.is_some() { + self.run_ctx.interrupt_mode = if checkpoint.deadline.is_some() { InterruptMode::Epoch } else { InterruptMode::None }; - self.epoch_deadline = checkpoint.deadline.unwrap_or(0); - self.epoch_deadline_delta = checkpoint.deadline_delta; - self.epoch_rearm_pending = checkpoint.rearm_pending; - self.fuel_check_interval = checkpoint.check_interval.max(1); - self.fuel_ops_until_check = checkpoint + self.run_ctx.epoch_deadline = checkpoint.deadline.unwrap_or(0); + self.run_ctx.epoch_deadline_delta = checkpoint.deadline_delta; + self.run_ctx.epoch_rearm_pending = checkpoint.rearm_pending; + self.run_ctx.fuel_check_interval = checkpoint.check_interval.max(1); + self.run_ctx.fuel_ops_until_check = checkpoint .ops_until_check - .clamp(1, self.fuel_check_interval); + .clamp(1, self.run_ctx.fuel_check_interval); } pub fn last_yield_reason(&self) -> Option { - self.last_yield_reason + self.instance.last_yield_reason } } diff --git a/src/vm/fuel.rs b/src/vm/fuel.rs index f7a3e9d1..4af4b090 100644 --- a/src/vm/fuel.rs +++ b/src/vm/fuel.rs @@ -19,61 +19,28 @@ impl FuelCheckpoint { impl Vm { pub(super) fn pending_fuel_debt(&self) -> u64 { - if !self.fuel_metering_enabled() { - return 0; - } - let executed_since_last_check = self - .fuel_check_interval - .saturating_sub(self.fuel_ops_until_check); - u64::from(executed_since_last_check) + self.run_ctx.pending_fuel_debt() } #[inline(always)] pub(in crate::vm) fn charge_fuel(&mut self, amount: u64) -> VmResult<()> { - if amount == 0 || !self.fuel_metering_enabled() { - return Ok(()); - } - - let remaining = self.fuel_remaining; - if remaining < amount { - return Err(VmError::OutOfFuel { - needed: amount, - remaining, - }); - } - self.fuel_remaining = remaining - amount; - Ok(()) + self.run_ctx.charge_fuel(amount) } #[inline(always)] pub(in crate::vm) fn charge_fuel_tick(&mut self) -> VmResult<()> { - if !self.fuel_metering_enabled() { - return Ok(()); - } - if self.fuel_ops_until_check > 1 { - self.fuel_ops_until_check -= 1; - return Ok(()); - } - - let amount = u64::from(self.fuel_check_interval); - self.charge_fuel(amount)?; - self.fuel_ops_until_check = self.fuel_check_interval; - Ok(()) + self.run_ctx.charge_fuel_tick() } pub(super) fn clear_fuel_internal(&mut self) { - if self.fuel_metering_enabled() { - self.interrupt_mode = InterruptMode::None; - } - self.fuel_remaining = 0; - self.reset_interrupt_countdown(); + self.run_ctx.clear_fuel_internal(); } pub fn set_fuel(&mut self, fuel: u64) { - self.clear_epoch_deadline_internal(); - self.interrupt_mode = InterruptMode::Fuel; - self.fuel_remaining = fuel; - self.reset_interrupt_countdown(); + self.run_ctx.clear_epoch_deadline_internal(); + self.run_ctx.interrupt_mode = InterruptMode::Fuel; + self.run_ctx.fuel_remaining = fuel; + self.run_ctx.reset_interrupt_countdown(); } pub fn clear_fuel(&mut self) { @@ -87,18 +54,21 @@ impl Vm { if self.epoch_interruption_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Fuel)); } - self.fuel_check_interval = interval; - self.reset_interrupt_countdown(); + self.run_ctx.fuel_check_interval = interval; + self.run_ctx.reset_interrupt_countdown(); Ok(()) } pub fn fuel_check_interval(&self) -> u32 { - self.fuel_check_interval + self.run_ctx.fuel_check_interval } pub fn get_fuel(&self) -> Option { - self.fuel_metering_enabled() - .then_some(self.fuel_remaining.saturating_sub(self.pending_fuel_debt())) + self.fuel_metering_enabled().then_some( + self.run_ctx + .fuel_remaining + .saturating_sub(self.pending_fuel_debt()), + ) } pub fn add_fuel(&mut self, fuel: u64) -> VmResult<()> { @@ -108,13 +78,14 @@ impl Vm { if self.epoch_interruption_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Fuel)); } - self.fuel_remaining = if self.fuel_metering_enabled() { - self.fuel_remaining + self.run_ctx.fuel_remaining = if self.fuel_metering_enabled() { + self.run_ctx + .fuel_remaining .checked_add(fuel) .ok_or(VmError::FuelOverflow)? } else { - self.interrupt_mode = InterruptMode::Fuel; - self.reset_interrupt_countdown(); + self.run_ctx.interrupt_mode = InterruptMode::Fuel; + self.run_ctx.reset_interrupt_countdown(); fuel }; Ok(()) @@ -140,9 +111,11 @@ impl Vm { pub fn fuel_checkpoint(&self) -> FuelCheckpoint { FuelCheckpoint { - remaining: self.fuel_metering_enabled().then_some(self.fuel_remaining), + remaining: self + .fuel_metering_enabled() + .then_some(self.run_ctx.fuel_remaining), check_interval: self.fuel_check_interval(), - ops_until_check: self.fuel_ops_until_check, + ops_until_check: self.run_ctx.fuel_ops_until_check, } } @@ -151,17 +124,17 @@ impl Vm { } pub fn restore_fuel(&mut self, checkpoint: FuelCheckpoint) { - self.clear_epoch_deadline_internal(); - self.interrupt_mode = if checkpoint.remaining.is_some() { + self.run_ctx.clear_epoch_deadline_internal(); + self.run_ctx.interrupt_mode = if checkpoint.remaining.is_some() { InterruptMode::Fuel } else { InterruptMode::None }; - self.fuel_remaining = checkpoint.remaining.unwrap_or(0); - self.fuel_check_interval = checkpoint.check_interval.max(1); - self.fuel_ops_until_check = checkpoint + self.run_ctx.fuel_remaining = checkpoint.remaining.unwrap_or(0); + self.run_ctx.fuel_check_interval = checkpoint.check_interval.max(1); + self.run_ctx.fuel_ops_until_check = checkpoint .ops_until_check - .clamp(1, self.fuel_check_interval); + .clamp(1, self.run_ctx.fuel_check_interval); } pub fn restore_checkpoint(&mut self, checkpoint: FuelCheckpoint) { diff --git a/src/vm/host.rs b/src/vm/host.rs index cf8826a0..d139079c 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, RwLock}; use std::task::{Context, Poll, Wake, Waker}; @@ -89,6 +90,10 @@ pub trait HostAsyncBridge: Send { fn poll_op(&mut self, op_id: HostOpId, cx: &mut Context<'_>) -> Poll>; fn cancel_op(&mut self, _op_id: HostOpId) {} + + fn cancel_op_with_reason(&mut self, op_id: HostOpId, _reason: CancellationReason) { + self.cancel_op(op_id); + } } pub type StaticHostFunction = fn(&mut Vm, &[Value]) -> VmResult; @@ -114,6 +119,7 @@ enum RegistryEntryKind { struct RegistryEntry { arity: u8, kind: RegistryEntryKind, + runtime_owned_pending: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -121,6 +127,16 @@ pub struct HostBindingPlan { import_signature: Vec, registry_slots: Vec, resolved_calls: Vec, + runtime_owned_pending_slots: Vec, + allowed_builtin_calls: Vec, + allow_default_builtin_capabilities: bool, + allowed_host_function_slots: Vec, + allow_default_host_capabilities: bool, + capability_profile: Arc<()>, + capability_state: Arc<()>, + registry_state: Arc<()>, + registry_generation_token: Arc<()>, + registry_generation: u64, } #[derive(Clone)] @@ -128,6 +144,15 @@ pub struct HostFunctionRegistry { entries: Arc>, by_name: Arc>, plan_cache: Arc, Arc>>>, + allowed_builtin_calls: Arc>, + allow_default_builtin_capabilities: bool, + allowed_host_registry_slots: Arc>, + allow_default_host_capabilities: bool, + capability_profile: Arc<()>, + capability_state: Arc<()>, + registry_state: Arc<()>, + registry_generation_token: Arc<()>, + registry_generation: Arc, } impl Default for HostFunctionRegistry { @@ -142,25 +167,98 @@ impl HostFunctionRegistry { entries: Arc::new(Vec::new()), by_name: Arc::new(HashMap::new()), plan_cache: Arc::new(RwLock::new(HashMap::new())), + allowed_builtin_calls: Arc::new(Vec::new()), + allow_default_builtin_capabilities: true, + allowed_host_registry_slots: Arc::new(Vec::new()), + allow_default_host_capabilities: true, + capability_profile: Arc::new(()), + capability_state: Arc::new(()), + registry_state: Arc::new(()), + registry_generation_token: Arc::new(()), + registry_generation: Arc::new(AtomicU64::new(0)), } } pub fn new() -> Self { static DEFAULT_REGISTRY: OnceLock = OnceLock::new(); - DEFAULT_REGISTRY + let mut registry = DEFAULT_REGISTRY .get_or_init(|| { let mut registry = Self::empty(); crate::builtins::runtime::register_default_host_functions(&mut registry); + registry.allow_default_builtin_capabilities = true; + registry.allow_default_host_capabilities = true; registry }) - .clone() + .clone(); + registry.plan_cache = Arc::new(RwLock::new(HashMap::new())); + registry.capability_profile = Arc::new(()); + registry.capability_state = Arc::new(()); + registry.registry_state = Arc::new(()); + registry.registry_generation_token = Arc::new(()); + registry.registry_generation = Arc::new(AtomicU64::new(0)); + 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.allowed_host_registry_slots = Arc::new(Vec::new()); + registry.capability_profile = Arc::new(()); + registry.capability_state = Arc::new(()); + registry.registry_state = Arc::new(()); + registry.registry_generation_token = Arc::new(()); + registry.registry_generation = Arc::new(AtomicU64::new(0)); + registry.invalidate_plan_cache(); + registry + } + + /// 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 let Some(®istry_slot) = self.by_name.get(name) { + let slots = Arc::make_mut(&mut self.allowed_host_registry_slots); + if !slots.contains(®istry_slot) { + slots.push(registry_slot); + slots.sort_unstable(); + } + self.capability_state = Arc::new(()); + 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_state = Arc::new(()); + 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())); } + pub(crate) fn mark_runtime_owned_pending(&mut self, name: &str) { + let slot = self + .by_name + .get(name) + .copied() + .expect("generated runtime host function should be registered"); + let entry = Arc::make_mut(&mut self.entries) + .get_mut(slot as usize) + .expect("generated runtime host function slot should exist"); + entry.runtime_owned_pending = true; + } + pub fn register(&mut self, name: impl Into, arity: u8, factory: F) where F: Fn() -> Box + Send + Sync + 'static, @@ -170,6 +268,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::Factory(Arc::new(factory)); self.invalidate_plan_cache(); return; @@ -179,6 +278,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::Factory(Arc::new(factory)), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -196,6 +296,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::Static(function); self.invalidate_plan_cache(); return; @@ -205,6 +306,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::Static(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -220,6 +322,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::StackFactory(Arc::new(factory)); self.invalidate_plan_cache(); return; @@ -229,6 +332,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::StackFactory(Arc::new(factory)), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -246,6 +350,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::StackStatic(function); self.invalidate_plan_cache(); return; @@ -255,6 +360,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::StackStatic(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -270,6 +376,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::ArgsFactory(Arc::new(factory)); self.invalidate_plan_cache(); return; @@ -279,6 +386,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::ArgsFactory(Arc::new(factory)), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -296,6 +404,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::ArgsStatic(function); self.invalidate_plan_cache(); return; @@ -305,6 +414,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::ArgsStatic(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -328,6 +438,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::ArgsStaticNonYielding(function); self.invalidate_plan_cache(); return; @@ -337,6 +448,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::ArgsStaticNonYielding(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -356,6 +468,17 @@ impl HostFunctionRegistry { self.plan_for_imports(imports) } + fn plan_matches_current(&self, plan: &HostBindingPlan) -> bool { + Arc::ptr_eq(&self.capability_profile, &plan.capability_profile) + && Arc::ptr_eq(&self.capability_state, &plan.capability_state) + && 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> { if let Some(plan) = self .plan_cache @@ -363,6 +486,7 @@ impl HostFunctionRegistry { .expect("host binding plan cache read lock should not be poisoned") .get(imports) .cloned() + && self.plan_matches_current(&plan) { return Ok(plan); } @@ -400,17 +524,48 @@ impl HostFunctionRegistry { resolved_calls.push(vm_slot); } + let allowed_host_function_slots = self + .allowed_host_registry_slots + .iter() + .filter_map(|registry_slot| { + registry_slots + .iter() + .position(|slot| slot == registry_slot) + .map(|slot| slot as u16) + }) + .collect(); + let runtime_owned_pending_slots = registry_slots + .iter() + .enumerate() + .filter_map(|(vm_slot, registry_slot)| { + self.entries + .get(*registry_slot as usize) + .filter(|entry| entry.runtime_owned_pending) + .map(|_| vm_slot as u16) + }) + .collect(); let import_key = imports.to_vec(); let computed = Arc::new(HostBindingPlan { import_signature: import_key.clone(), registry_slots, resolved_calls, + runtime_owned_pending_slots, + 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_state: Arc::clone(&self.capability_state), + 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(import_key, Arc::clone(&computed)); + Ok(computed) } pub fn bind_vm_with_plan(&self, vm: &mut Vm, plan: &HostBindingPlan) -> VmResult<()> { @@ -419,13 +574,37 @@ impl HostFunctionRegistry { "host binding plan does not match vm import signature".to_string(), )); } - if !vm.host_functions.is_empty() || !vm.host_function_symbols.is_empty() { + if !Arc::ptr_eq(&self.capability_profile, &plan.capability_profile) { + return Err(VmError::HostError( + "host binding plan belongs to a different capability profile".to_string(), + )); + } + if !Arc::ptr_eq(&self.capability_state, &plan.capability_state) { + return Err(VmError::HostError( + "host binding plan belongs to a different capability state".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(), )); } - vm.host_functions.reserve(plan.registry_slots.len()); + vm.host.host_functions.reserve(plan.registry_slots.len()); for ®istry_slot in &plan.registry_slots { let entry = self .entries @@ -455,8 +634,14 @@ impl HostFunctionRegistry { } } } - vm.install_resolved_calls(plan.resolved_calls.clone())?; 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.host.runtime_owned_pending_host_slots = + plan.runtime_owned_pending_slots.iter().copied().collect(); + vm.install_resolved_calls(plan.resolved_calls.clone())?; Ok(()) } } @@ -534,13 +719,6 @@ pub(crate) fn validate_non_yielding_host_value( #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct WaitingHostOp { pub(super) op_id: HostOpId, - pub(super) source: WaitingHostOpSource, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum WaitingHostOpSource { - HostBridge, - BuiltinIo, } struct NoopWake; @@ -563,48 +741,56 @@ fn builtin_for_binding_name(name: &str) -> Option { impl Vm { pub fn register_function(&mut self, function: Box) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions.push(VmHostFunction::Dynamic(function)); - self.resolved_calls_dirty = true; + let index = self.host.host_functions.len() as u16; + self.host + .host_functions + .push(VmHostFunction::Dynamic(function)); + self.host.resolved_calls_dirty = true; index } pub fn register_static_function(&mut self, function: StaticHostFunction) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions.push(VmHostFunction::Static(function)); - self.resolved_calls_dirty = true; + let index = self.host.host_functions.len() as u16; + self.host + .host_functions + .push(VmHostFunction::Static(function)); + self.host.resolved_calls_dirty = true; index } pub fn register_stack_function(&mut self, function: Box) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::StackDynamic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } pub fn register_static_stack_function(&mut self, function: StaticHostStackFunction) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::StackStatic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } pub fn register_args_function(&mut self, function: Box) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::ArgsDynamic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } pub fn register_static_args_function(&mut self, function: StaticHostArgsFunction) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::ArgsStatic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } @@ -618,49 +804,79 @@ impl Vm { &mut self, function: StaticHostArgsFunction, ) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::ArgsStaticNonYielding(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } + fn clear_runtime_owned_pending_binding(&mut self, name: &str) { + let slot = builtin_for_binding_name(name) + .and_then(|builtin| { + self.host + .builtin_overrides + .get(&builtin.call_index()) + .copied() + }) + .or_else(|| self.host.host_function_symbols.get(name).copied()); + if let Some(slot) = slot { + self.host.runtime_owned_pending_host_slots.remove(&slot); + } + } + + pub(crate) fn mark_runtime_owned_pending_binding(&mut self, name: &str) { + let slot = builtin_for_binding_name(name) + .and_then(|builtin| { + self.host + .builtin_overrides + .get(&builtin.call_index()) + .copied() + }) + .or_else(|| self.host.host_function_symbols.get(name).copied()) + .expect("generated runtime host binding should exist"); + self.host.runtime_owned_pending_host_slots.insert(slot); + } + pub fn bind_function(&mut self, name: impl Into, function: Box) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot(builtin.call_index(), VmHostFunction::Dynamic(function)); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::Dynamic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_static_function(&mut self, name: impl Into, function: StaticHostFunction) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot(builtin.call_index(), VmHostFunction::Static(function)); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::Static(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_stack_function( @@ -669,17 +885,18 @@ impl Vm { function: Box, ) { let name = name.into(); - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + self.clear_runtime_owned_pending_binding(&name); + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::StackDynamic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_stack_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_static_stack_function( @@ -688,6 +905,7 @@ impl Vm { function: StaticHostStackFunction, ) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot( builtin.call_index(), @@ -695,17 +913,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::StackStatic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_stack_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_args_function( @@ -714,6 +932,7 @@ impl Vm { function: Box, ) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot( builtin.call_index(), @@ -721,17 +940,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::ArgsDynamic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_args_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_static_args_function( @@ -740,6 +959,7 @@ impl Vm { function: StaticHostArgsFunction, ) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot( builtin.call_index(), @@ -747,17 +967,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::ArgsStatic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_args_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } /// Binds a static args-only host function that always returns one value synchronously. @@ -772,6 +992,7 @@ impl Vm { function: StaticHostArgsFunction, ) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot( builtin.call_index(), @@ -779,17 +1000,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::ArgsStaticNonYielding(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_non_yielding_args_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_builtin_override( @@ -801,6 +1022,7 @@ impl Vm { let builtin = BuiltinFunction::from_namespaced_name(&name).ok_or_else(|| { VmError::HostError(format!("unknown namespaced builtin override '{name}'")) })?; + self.clear_runtime_owned_pending_binding(&name); self.bind_builtin_overrideslot(builtin.call_index(), VmHostFunction::Dynamic(function)); Ok(()) } @@ -814,54 +1036,154 @@ impl Vm { let builtin = BuiltinFunction::from_namespaced_name(&name).ok_or_else(|| { VmError::HostError(format!("unknown namespaced builtin override '{name}'")) })?; + self.clear_runtime_owned_pending_binding(&name); self.bind_builtin_overrideslot(builtin.call_index(), VmHostFunction::Static(function)); Ok(()) } fn bind_builtin_overrideslot(&mut self, builtin_call_index: u16, function: VmHostFunction) { - if let Some(&host_slot) = self.builtin_overrides.get(&builtin_call_index) - && let Some(slot) = self.host_functions.get_mut(host_slot as usize) + if let Some(&host_slot) = self.host.builtin_overrides.get(&builtin_call_index) + && let Some(slot) = self.host.host_functions.get_mut(host_slot as usize) { *slot = function; return; } - let host_slot = self.host_functions.len() as u16; - self.host_functions.push(function); - self.builtin_overrides.insert(builtin_call_index, host_slot); + let host_slot = self.host.host_functions.len() as u16; + self.host.host_functions.push(function); + self.host + .builtin_overrides + .insert(builtin_call_index, host_slot); } pub fn set_async_bridge(&mut self, bridge: Box) { self.cancel_waiting_host_op(); - self.async_bridge = Some(bridge); + self.host.async_bridge = Some(bridge); } pub fn clear_async_bridge(&mut self) { self.cancel_waiting_host_op(); - self.async_bridge = None; + self.host.async_bridge = None; } pub fn set_runtime_print_sink(&mut self, sink: F) where F: FnMut(String) + Send + 'static, { - self.runtime_print_sink = Some(Box::new(sink)); + self.host.runtime_print_sink = Some(Box::new(sink)); } pub fn clear_runtime_print_sink(&mut self) { - self.runtime_print_sink = None; + self.host.runtime_print_sink = None; + } + + pub fn set_runtime_input(&mut self, value: Value) -> VmResult<()> { + self.run_ctx + .runtime_context + .set_input(value) + .map_err(|error| VmError::HostError(error.to_string())) + } + + pub fn clear_runtime_input(&mut self) { + self.run_ctx.runtime_context.clear_input(); + } + + pub fn set_runtime_event_sink(&mut self, sink: S) -> VmResult<()> + where + S: crate::builtins::runtime::event::EventSink + 'static, + { + self.run_ctx + .runtime_context + .set_event_sink(sink) + .map_err(|error| VmError::HostError(error.to_string())) + } + + pub fn clear_runtime_event_sink(&mut self) { + self.run_ctx.runtime_context.clear_event_sink(); + } + + pub(crate) fn runtime_input_value(&self) -> VmResult { + crate::builtins::runtime::context::runtime_input(&self.run_ctx.runtime_context) + } + + pub(crate) fn emit_runtime_event(&mut self, value: Value) -> VmResult<()> { + crate::builtins::runtime::context::runtime_emit(&mut self.run_ctx.runtime_context, value) + } + + /// Configure a bounded event sink without exposing runtime implementation types. + pub fn set_runtime_value_event_sink(&mut self, mut sink: F) -> VmResult<()> + where + F: FnMut(Value) -> VmResult<()> + Send + 'static, + { + self.run_ctx + .runtime_context + .set_event_sink( + move |payload: crate::builtins::runtime::event::EventPayload| { + sink(payload.into_value()).map_err(|error| { + crate::builtins::runtime::error::RuntimeError::new( + crate::builtins::runtime::error::RuntimeErrorCode::EventSinkRejected, + "runtime::emit", + error.to_string(), + ) + }) + }, + ) + .map_err(|error| VmError::HostError(error.to_string())) + } + + #[cfg(feature = "sqlite")] + pub fn configure_sqlite(&mut self, policy: crate::vm::SqlitePolicy) { + crate::builtins::runtime::cancel_operations_by_owner( + self, + crate::builtins::runtime::cancellation::OperationOwner::Sqlite, + crate::builtins::runtime::cancellation::CancellationReason::ResourceClosed, + ); + crate::builtins::runtime::close_resources_by_type( + self, + crate::builtins::runtime::resource::ResourceTypeId::SQLITE_CONNECTION, + crate::builtins::runtime::cancellation::CancellationReason::ResourceClosed, + ); + self.host.sqlite_policy = policy; + } + + #[cfg(feature = "sqlite")] + pub fn clear_sqlite_configuration(&mut self) { + crate::builtins::runtime::cancel_operations_by_owner( + self, + crate::builtins::runtime::cancellation::OperationOwner::Sqlite, + crate::builtins::runtime::cancellation::CancellationReason::ResourceClosed, + ); + crate::builtins::runtime::close_resources_by_type( + self, + crate::builtins::runtime::resource::ResourceTypeId::SQLITE_CONNECTION, + crate::builtins::runtime::cancellation::CancellationReason::ResourceClosed, + ); + self.host.sqlite_policy = crate::vm::SqlitePolicy::default(); } pub fn configure_http(&mut self, config: crate::builtins::runtime::HttpConfig) { - self.http_state.configure(config); + self.host.http_state.configure(config); + } + + pub fn set_http_max_in_flight(&mut self, max_in_flight: usize) { + self.host.http_state.max_in_flight = max_in_flight; + } + + pub fn http_max_in_flight(&self) -> usize { + self.host.http_state.max_in_flight } pub fn clear_http_configuration(&mut self) { - self.http_state.clear_configuration(); + crate::builtins::runtime::cancel_operations_by_owner( + self, + crate::builtins::runtime::cancellation::OperationOwner::Http, + crate::builtins::runtime::cancellation::CancellationReason::Requested, + ); + self.host.http_state.clear_configuration(); } pub fn http_is_configured(&self) -> bool { - self.http_state.is_configured() + self.host.http_state.is_configured() } /// Enables or disables implicit binding of built-in host functions. @@ -869,16 +1191,16 @@ impl Vm { /// 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.allow_default_host_fallback = enabled; - self.resolved_calls_dirty = true; + self.host.allow_default_host_fallback = enabled; + self.host.resolved_calls_dirty = true; } pub fn default_host_fallback_enabled(&self) -> bool { - self.allow_default_host_fallback + self.host.allow_default_host_fallback } pub(crate) fn write_runtime_print(&mut self, rendered: String) -> VmResult<()> { - let Some(sink) = self.runtime_print_sink.as_mut() else { + let Some(sink) = self.host.runtime_print_sink.as_mut() else { return Err(VmError::HostError( "runtime print sink is not configured".to_string(), )); @@ -888,28 +1210,48 @@ impl Vm { } pub fn allocate_host_op_id(&mut self) -> HostOpId { - let op_id = self.next_host_op_id; - self.next_host_op_id = self.next_host_op_id.wrapping_add(1).max(1); - op_id + self.host + .runtime_operations + .allocate_id() + .expect("host operation id space should not be exhausted") + .raw() } pub fn waiting_host_op_id(&self) -> Option { - self.waiting_host_op.map(|op| op.op_id) + self.instance.waiting_host_op.map(|op| op.op_id) } pub fn cancel_waiting_host_op(&mut self) { - let Some(waiting) = self.waiting_host_op.take() else { + self.cancel_waiting_host_op_with_reason( + crate::builtins::runtime::cancellation::CancellationReason::Requested, + ); + } + + pub(crate) fn cancel_waiting_host_op_with_reason( + &mut self, + reason: crate::builtins::runtime::cancellation::CancellationReason, + ) { + let Some(waiting) = self.instance.waiting_host_op.take() else { return; }; - match waiting.source { - WaitingHostOpSource::HostBridge => { - if let Some(bridge) = self.async_bridge.as_mut() { - bridge.cancel_op(waiting.op_id); - } - } - WaitingHostOpSource::BuiltinIo => { - crate::builtins::runtime::cancel_builtin_io_op(self, waiting.op_id); + let Ok(operation_id) = + crate::builtins::runtime::cancellation::OperationId::from_raw(waiting.op_id) + else { + return; + }; + let owner = self + .host + .runtime_operations + .get(operation_id) + .ok() + .map(|operation| operation.owner()); + if owner == Some(crate::builtins::runtime::cancellation::OperationOwner::HostBridge) { + if let Some(bridge) = self.host.async_bridge.as_mut() { + bridge.cancel_op_with_reason(waiting.op_id, reason); } + let _ = self.host.runtime_operations.cancel(operation_id, reason); + } else { + crate::builtins::runtime::cancel_builtin_io_op_with_reason(self, waiting.op_id, reason); } } @@ -918,41 +1260,93 @@ impl Vm { op_id: HostOpId, values: impl Into, ) -> VmResult<()> { + let waiting = self.instance.waiting_host_op.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 operation_id = crate::builtins::runtime::cancellation::OperationId::from_raw(op_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + let operation = self + .host + .runtime_operations + .get(operation_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + if operation.owner() != crate::builtins::runtime::cancellation::OperationOwner::HostBridge { + return Err(VmError::HostError(format!( + "host bridge cannot complete runtime-owned operation {op_id}", + ))); + } + self.host + .runtime_operations + .complete(operation_id) + .map_err(|error| VmError::HostError(error.to_string()))?; self.complete_waiting_host_op(op_id, values.into()) } pub fn poll_waiting_host_op(&mut self, cx: &mut Context<'_>) -> Poll> { - let Some(waiting) = self.waiting_host_op else { + let Some(waiting) = self.instance.waiting_host_op else { return Poll::Ready(Ok(())); }; - - let poll_result = match waiting.source { - WaitingHostOpSource::HostBridge => { - let bridge_ptr = match self.async_bridge.as_mut() { - Some(bridge) => bridge.as_mut() as *mut dyn HostAsyncBridge, - None => { - return Poll::Ready(Err(VmError::HostError(format!( - "vm waiting on host op {} without an async bridge", - waiting.op_id - )))); - } - }; - - unsafe { (&mut *bridge_ptr).poll_op(waiting.op_id, cx) } - } - WaitingHostOpSource::BuiltinIo => { - crate::builtins::runtime::poll_builtin_io_op(self, waiting.op_id, cx) - } + let operation_id = + match crate::builtins::runtime::cancellation::OperationId::from_raw(waiting.op_id) { + Ok(operation_id) => operation_id, + Err(error) => return Poll::Ready(Err(VmError::HostError(error.to_string()))), + }; + let operation = match self.host.runtime_operations.get(operation_id) { + Ok(operation) => operation, + Err(error) => return Poll::Ready(Err(VmError::HostError(error.to_string()))), + }; + let host_bridge_owned = + operation.owner() == crate::builtins::runtime::cancellation::OperationOwner::HostBridge; + + let poll_result = if host_bridge_owned { + let bridge_ptr = match self.host.async_bridge.as_mut() { + Some(bridge) => bridge.as_mut() as *mut dyn HostAsyncBridge, + None => { + return Poll::Ready(Err(VmError::HostError(format!( + "vm waiting on host op {} without an async bridge", + waiting.op_id + )))); + } + }; + unsafe { (&mut *bridge_ptr).poll_op(waiting.op_id, cx) } + } else { + crate::builtins::runtime::poll_builtin_io_op(self, waiting.op_id, cx) }; match poll_result { Poll::Pending => Poll::Pending, Poll::Ready(Ok(values)) => { + if host_bridge_owned { + self.host + .runtime_operations + .complete(operation_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + } self.complete_waiting_host_op(waiting.op_id, values)?; Poll::Ready(Ok(())) } Poll::Ready(Err(err)) => { - self.waiting_host_op = None; + if host_bridge_owned { + let runtime_error = crate::builtins::runtime::error::RuntimeError::new( + crate::builtins::runtime::error::RuntimeErrorCode::OperationFailed, + "runtime::host_bridge", + err.to_string(), + ) + .with_value(waiting.op_id); + let _ = self + .host + .runtime_operations + .fail(operation_id, runtime_error); + } + self.instance.waiting_host_op = None; Poll::Ready(Err(err)) } } @@ -992,7 +1386,11 @@ impl Vm { let mut cx = Context::from_waker(&waker); loop { if should_cancel() { + let cancellation_result = self + .run_ctx + .cancel(crate::builtins::runtime::cancellation::CancellationReason::Requested); self.cancel_waiting_host_op(); + cancellation_result?; return Err(VmError::HostError("host operation cancelled".to_string())); } match self.poll_waiting_host_op(&mut cx) { @@ -1021,6 +1419,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(), @@ -1028,7 +1432,7 @@ impl Vm { got: argc_u8, }); } - if self.builtin_overrides.contains_key(&index) { + if self.host.builtin_overrides.contains_key(&index) { return self.execute_builtin_override_call(index, argc_u8, call_ip); } if let Some(outcome) = @@ -1049,13 +1453,28 @@ impl Vm { .get(usize::from(index)) .map(|import| import.return_type); let resolved_index = self.resolve_call_target(index, argc_u8)?; - if let Some(function) = - self.host_functions - .get(resolved_index as usize) - .and_then(|function| match function { - VmHostFunction::ArgsStaticNonYielding(function) => Some(*function), - _ => None, - }) + 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 + .get(resolved_index as usize) + .and_then(|function| match function { + VmHostFunction::ArgsStaticNonYielding(function) => Some(*function), + _ => None, + }) { return self.execute_static_non_yielding_args_host_function( function, @@ -1084,6 +1503,7 @@ impl Vm { call_ip: usize, ) -> VmResult { let resolved_index = self + .host .builtin_overrides .get(&builtin_call_index) .copied() @@ -1109,32 +1529,36 @@ impl Vm { call_ip: usize, ) -> VmResult { let arg_start = self + .instance .stack .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.stack` until this borrowed slice is consumed. + // The builtin runtime must not mutate `self.instance.stack` until this borrowed slice is consumed. let outcome = unsafe { - let args = std::slice::from_raw_parts_mut(self.stack.as_mut_ptr().add(arg_start), argc); + 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) }?; match outcome { crate::builtins::runtime::BuiltinCallOutcome::Return(values) => { - self.stack.truncate(arg_start); - values.push_onto_stack(&mut self.stack); + self.instance.stack.truncate(arg_start); + values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) } crate::builtins::runtime::BuiltinCallOutcome::Halt => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); Ok(HostCallExecOutcome::Halted) } crate::builtins::runtime::BuiltinCallOutcome::Pending(op_id) => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, WaitingHostOpSource::BuiltinIo)?; - self.ip = resume_ip; + self.set_waiting_registered_op(op_id)?; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1147,13 +1571,14 @@ impl Vm { call_ip: usize, ) -> VmResult> { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; let (lhs, rhs) = self.operand_value_types(call_ip); let result = { - let args = &self.stack[arg_start..]; + let args = &self.instance.stack[arg_start..]; match builtin { BuiltinFunction::Len => match (lhs, args) { ( @@ -1221,8 +1646,8 @@ impl Vm { let Some(value) = result else { return Ok(None); }; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); self.record_typed_builtin_fast_path(); Ok(Some(HostCallExecOutcome::Returned)) } @@ -1233,12 +1658,13 @@ impl Vm { argc: usize, ) -> VmResult> { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; let result = { - let args = &self.stack[arg_start..]; + let args = &self.instance.stack[arg_start..]; match (builtin, args) { (BuiltinFunction::Len, [value]) => Self::fast_path_len_result(value), (BuiltinFunction::Get, [container, key]) => { @@ -1253,8 +1679,8 @@ impl Vm { let Some(value) = result else { return Ok(None); }; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); self.record_projection_fast_path(); Ok(Some(HostCallExecOutcome::Returned)) } @@ -1515,14 +1941,16 @@ impl Vm { call_ip: usize, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - let mut saved_stack = std::mem::take(&mut self.stack); - self.call_depth += 1; + let mut saved_stack = std::mem::take(&mut self.instance.stack); + self.instance.call_depth += 1; let function_ptr = - self.host_functions + self.host + .host_functions .get_mut(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))? as *mut VmHostFunction; let outcome = unsafe { @@ -1537,15 +1965,15 @@ impl Vm { | VmHostFunction::ArgsStaticNonYielding(_) => unreachable!(), } }; - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); - let mut host_stack = std::mem::take(&mut self.stack); + let mut host_stack = std::mem::take(&mut self.instance.stack); let outcome = match outcome { Ok(outcome) => outcome, Err(err) => { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; return Err(err); } }; @@ -1555,28 +1983,28 @@ impl Vm { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); values.push_onto_stack(&mut saved_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; Ok(HostCallExecOutcome::Returned) } CallOutcome::Halt => { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; Ok(HostCallExecOutcome::Halted) } CallOutcome::Yield => { saved_stack.append(&mut host_stack); - self.stack = saved_stack; - self.ip = call_ip; + self.instance.stack = saved_stack; + self.instance.ip = call_ip; Ok(HostCallExecOutcome::Yielded) } CallOutcome::Pending(op_id) => { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, self.pending_host_op_source(op_id))?; - self.ip = resume_ip; + self.set_waiting_bound_host_op(resolved_index, op_id)?; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1584,6 +2012,7 @@ impl Vm { fn bound_host_function_uses_args_slice(&self, resolved_index: u16) -> VmResult { let function = self + .host .host_functions .get(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))?; @@ -1597,6 +2026,7 @@ impl Vm { fn bound_host_function_uses_stack_borrow(&self, resolved_index: u16) -> VmResult { let function = self + .host .host_functions .get(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))?; @@ -1614,17 +2044,18 @@ impl Vm { expected_return_type: Option, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - self.call_depth += 1; - let outcome = function(&self.stack[arg_start..]); - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth += 1; + 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)?; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); Ok(HostCallExecOutcome::Returned) } @@ -1636,14 +2067,16 @@ impl Vm { expected_return_type: Option, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - self.call_depth += 1; + self.instance.call_depth += 1; let outcome = { - let args = &self.stack[arg_start..]; + let args = &self.instance.stack[arg_start..]; let function = self + .host .host_functions .get_mut(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))?; @@ -1657,36 +2090,36 @@ impl Vm { | VmHostFunction::StackStatic(_) => unreachable!(), } }; - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); let (outcome, non_yielding) = outcome; 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)?; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); return Ok(HostCallExecOutcome::Returned); } match outcome { CallOutcome::Return(values) => { - self.stack.truncate(arg_start); - values.push_onto_stack(&mut self.stack); + self.instance.stack.truncate(arg_start); + values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) } CallOutcome::Halt => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); Ok(HostCallExecOutcome::Halted) } CallOutcome::Yield => { - self.ip = call_ip; + self.instance.ip = call_ip; Ok(HostCallExecOutcome::Yielded) } CallOutcome::Pending(op_id) => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, self.pending_host_op_source(op_id))?; - self.ip = resume_ip; + self.set_waiting_bound_host_op(resolved_index, op_id)?; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1699,20 +2132,23 @@ impl Vm { call_ip: usize, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - self.call_depth += 1; + self.instance.call_depth += 1; let function_ptr = - self.host_functions + self.host + .host_functions .get_mut(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))? as *mut VmHostFunction; // Stack-borrowed host functions opt into the same raw stack-tail borrowing model used - // by builtin dispatch. They must not re-enter the VM or otherwise mutate `self.stack` + // by builtin dispatch. They must not re-enter the VM or otherwise mutate `self.instance.stack` // while the borrowed slice is alive. let outcome = unsafe { - let args = std::slice::from_raw_parts(self.stack.as_ptr().add(arg_start), argc); + let args = + std::slice::from_raw_parts(self.instance.stack.as_ptr().add(arg_start), argc); match &mut *function_ptr { VmHostFunction::StackDynamic(function) => function.call(self, args), VmHostFunction::StackStatic(function) => function(self, args), @@ -1723,28 +2159,28 @@ impl Vm { | VmHostFunction::ArgsStaticNonYielding(_) => unreachable!(), } }; - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); let outcome = outcome?; match outcome { CallOutcome::Return(values) => { - self.stack.truncate(arg_start); - values.push_onto_stack(&mut self.stack); + self.instance.stack.truncate(arg_start); + values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) } CallOutcome::Halt => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); Ok(HostCallExecOutcome::Halted) } CallOutcome::Yield => { - self.ip = call_ip; + self.instance.ip = call_ip; Ok(HostCallExecOutcome::Yielded) } CallOutcome::Pending(op_id) => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, self.pending_host_op_source(op_id))?; - self.ip = resume_ip; + self.set_waiting_bound_host_op(resolved_index, op_id)?; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1770,20 +2206,91 @@ impl Vm { Ok(resume_ip) } - fn pending_host_op_source(&self, op_id: HostOpId) -> WaitingHostOpSource { - if crate::builtins::runtime::is_builtin_io_op(self, op_id) { - WaitingHostOpSource::BuiltinIo + fn set_waiting_registered_op(&mut self, op_id: HostOpId) -> VmResult<()> { + let operation_id = crate::builtins::runtime::cancellation::OperationId::from_raw(op_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + let operation = self + .host + .runtime_operations + .get(operation_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + if operation.owner() == crate::builtins::runtime::cancellation::OperationOwner::HostBridge { + return Err(VmError::HostError(format!( + "builtin pending operation {op_id} is owned by the host bridge", + ))); + } + self.set_waiting_operation(op_id) + } + + fn set_waiting_bound_host_op(&mut self, resolved_index: u16, op_id: HostOpId) -> VmResult<()> { + if self + .host + .runtime_owned_pending_host_slots + .contains(&resolved_index) + { + self.set_waiting_registered_op(op_id) } else { - WaitingHostOpSource::HostBridge + self.set_waiting_host_op(op_id) } } - pub(super) fn set_waiting_host_op( - &mut self, - op_id: HostOpId, - source: WaitingHostOpSource, - ) -> VmResult<()> { - if let Some(active) = self.waiting_host_op + pub(super) fn set_waiting_host_op(&mut self, op_id: HostOpId) -> VmResult<()> { + let result = (|| { + let operation_id = crate::builtins::runtime::cancellation::OperationId::from_raw(op_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + self.host + .runtime_operations + .retire_external_id(operation_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + match self.host.runtime_operations.get(operation_id) { + Ok(operation) + if operation.owner() + == crate::builtins::runtime::cancellation::OperationOwner::HostBridge => {} + Ok(_) => { + return Err(VmError::HostError(format!( + "host bridge operation id {op_id} collides with a runtime-owned operation", + ))); + } + Err(_) => { + self.host + .runtime_operations + .register_retired_external( + operation_id, + crate::builtins::runtime::cancellation::OperationOwner::HostBridge, + Some(&self.run_ctx.cancellation), + None, + None, + ) + .map_err(|error| VmError::HostError(error.to_string()))?; + } + } + self.set_waiting_operation(op_id) + })(); + + if result.is_err() { + let reason = crate::builtins::runtime::cancellation::CancellationReason::ResourceClosed; + if let Some(bridge) = self.host.async_bridge.as_mut() { + bridge.cancel_op_with_reason(op_id, reason); + } + if let Ok(operation_id) = + crate::builtins::runtime::cancellation::OperationId::from_raw(op_id) + && self + .host + .runtime_operations + .get(operation_id) + .is_ok_and(|operation| { + operation.owner() + == crate::builtins::runtime::cancellation::OperationOwner::HostBridge + }) + { + let _ = self.host.runtime_operations.cancel(operation_id, reason); + } + } + result + } + + fn set_waiting_operation(&mut self, op_id: HostOpId) -> VmResult<()> { + if let Some(active) = self.instance.waiting_host_op && active.op_id != op_id { return Err(VmError::HostError(format!( @@ -1791,7 +2298,7 @@ impl Vm { active.op_id, op_id ))); } - self.waiting_host_op = Some(WaitingHostOp { op_id, source }); + self.instance.waiting_host_op = Some(WaitingHostOp { op_id }); Ok(()) } @@ -1800,7 +2307,7 @@ impl Vm { op_id: HostOpId, values: CallReturn, ) -> VmResult<()> { - let waiting = self.waiting_host_op.ok_or_else(|| { + let waiting = self.instance.waiting_host_op.ok_or_else(|| { VmError::HostError(format!( "host op {} completed but vm is not waiting on any op", op_id @@ -1812,8 +2319,8 @@ impl Vm { op_id, waiting.op_id ))); } - self.waiting_host_op = None; - values.push_onto_stack(&mut self.stack); + self.instance.waiting_host_op = None; + values.push_onto_stack(&mut self.instance.stack); Ok(()) } @@ -1826,23 +2333,23 @@ impl Vm { ))); } for &index in &resolved_calls { - if index as usize >= self.host_functions.len() { + if index as usize >= self.host.host_functions.len() { return Err(VmError::InvalidCall(index)); } } - self.resolved_calls = resolved_calls; - self.resolved_calls_dirty = false; + self.host.resolved_calls = resolved_calls; + self.host.resolved_calls_dirty = false; Ok(()) } pub(super) fn ensure_call_bindings(&mut self) -> VmResult<()> { - if self.program.imports.is_empty() || !self.resolved_calls_dirty { + if self.program.imports.is_empty() || !self.host.resolved_calls_dirty { return Ok(()); } - if self.allow_default_host_fallback - && self.host_function_symbols.is_empty() - && self.host_functions.is_empty() + if self.host.allow_default_host_fallback + && self.host.host_function_symbols.is_empty() + && self.host.host_functions.is_empty() { let import_names = self .program @@ -1855,51 +2362,54 @@ impl Vm { } } - let use_legacy_order = self.host_function_symbols.is_empty(); + let use_legacy_order = self.host.host_function_symbols.is_empty(); let mut resolved = Vec::with_capacity(self.program.imports.len()); let imports = self.program.imports.clone(); for (index, import) in imports.iter().enumerate() { if use_legacy_order { - if index >= self.host_functions.len() { + if index >= self.host.host_functions.len() { return Err(VmError::InvalidCall(index as u16)); } resolved.push(index as u16); continue; } - let bound = if let Some(bound) = self.host_function_symbols.get(&import.name).copied() { - bound - } else if self.allow_default_host_fallback - && crate::builtins::runtime::bind_default_host_function(self, &import.name) - { - self.host_function_symbols - .get(&import.name) - .copied() - .ok_or_else(|| VmError::UnboundImport(import.name.clone()))? - } else { - return Err(VmError::UnboundImport(import.name.clone())); - }; + let bound = + if let Some(bound) = self.host.host_function_symbols.get(&import.name).copied() { + bound + } else if self.host.allow_default_host_fallback + && crate::builtins::runtime::bind_default_host_function(self, &import.name) + { + self.host + .host_function_symbols + .get(&import.name) + .copied() + .ok_or_else(|| VmError::UnboundImport(import.name.clone()))? + } else { + return Err(VmError::UnboundImport(import.name.clone())); + }; resolved.push(bound); } - self.resolved_calls = resolved; - self.resolved_calls_dirty = false; + self.host.resolved_calls = resolved; + self.host.resolved_calls_dirty = false; Ok(()) } pub(super) fn sync_jit_non_yielding_host_imports(&mut self) { let imports = self + .host .resolved_calls .iter() .map(|&slot| { matches!( - self.host_functions.get(usize::from(slot)), + self.host.host_functions.get(usize::from(slot)), Some(VmHostFunction::ArgsStaticNonYielding(_)) ) }) .collect(); - if self.jit.set_non_yielding_host_imports(imports) { - self.native_traces.clear(); + if self.engine.jit.set_non_yielding_host_imports(imports) { + self.engine.native_traces.clear(); } } @@ -1922,7 +2432,8 @@ impl Vm { }); } - self.resolved_calls + self.host + .resolved_calls .get(index as usize) .copied() .ok_or(VmError::InvalidCall(index)) diff --git a/src/vm/host_runtime.rs b/src/vm/host_runtime.rs new file mode 100644 index 00000000..8d3712da --- /dev/null +++ b/src/vm/host_runtime.rs @@ -0,0 +1,103 @@ +//! Host runtime shell. +//! +//! [`HostRuntime`] owns the host-facing capability surface: bound host +//! functions and their symbol table, capability allow-lists, builtin +//! overrides, resolved call slots, the opaque resource arena, the pending +//! operation registry, and the IO/HTTP/SQLite subsystem state plus the async +//! bridge and print sink. Interpreter state and run budgets live outside this +//! struct (see [`Instance`](super::instance::Instance) and +//! [`RunContext`](super::run_context::RunContext)). +//! +//! The unified host-lifecycle plan migrates individual subsystems behind this +//! shell; for now it groups their ownership and their reset/drop behavior. + +use std::collections::{HashMap, HashSet}; + +use crate::builtins::runtime::HttpState; +use crate::builtins::runtime::cancellation::{ + CancellationReason, DEFAULT_MAX_PENDING_OPERATIONS, OperationRegistry, +}; +use crate::builtins::runtime::resource::{DEFAULT_MAX_RESOURCES, ResourceArena}; + +#[cfg(feature = "sqlite")] +use crate::vm::SqlitePolicy; +use crate::vm::host::{HostAsyncBridge, VmHostFunction}; + +/// Embedder-supplied print sink for `print`/`debug` output. +pub(crate) type RuntimePrintSink = dyn FnMut(String) + Send; + +/// Host-owned capabilities, resources, operations, and subsystem state. +/// +/// Thread safety: `HostRuntime` is `!Sync` (host functions, resources, and +/// operations are mutable and not shareable) and not shared; one facade owns +/// one host runtime. Clone semantics: not `Clone` — host bindings and resource +/// handles must not be duplicated across VMs. +pub(crate) struct HostRuntime { + pub(super) host_functions: Vec, + pub(crate) host_function_symbols: HashMap, + pub(crate) allow_default_host_fallback: bool, + pub(crate) allowed_builtin_calls: Vec, + pub(crate) allow_default_builtin_capabilities: bool, + pub(crate) allowed_host_function_slots: Vec, + pub(crate) allow_default_host_capabilities: bool, + pub(crate) builtin_overrides: HashMap, + pub(crate) runtime_owned_pending_host_slots: HashSet, + pub(crate) resolved_calls: Vec, + pub(crate) resolved_calls_dirty: bool, + pub(crate) runtime_resources: ResourceArena, + pub(crate) runtime_operations: OperationRegistry, + #[cfg(feature = "sqlite")] + pub(crate) sqlite_policy: SqlitePolicy, + pub(crate) http_state: HttpState, + pub(crate) async_bridge: Option>, + pub(crate) runtime_print_sink: Option>, +} + +impl HostRuntime { + /// Creates an empty host runtime with default capability and resource + /// limits and no bound functions. + pub(crate) fn new() -> Self { + Self { + host_functions: Vec::new(), + host_function_symbols: HashMap::new(), + allow_default_host_fallback: true, + allowed_builtin_calls: Vec::new(), + allow_default_builtin_capabilities: true, + allowed_host_function_slots: Vec::new(), + allow_default_host_capabilities: true, + builtin_overrides: HashMap::new(), + runtime_owned_pending_host_slots: HashSet::new(), + resolved_calls: Vec::new(), + resolved_calls_dirty: true, + runtime_resources: ResourceArena::with_limit(DEFAULT_MAX_RESOURCES) + .expect("default runtime resource limit should be valid"), + runtime_operations: OperationRegistry::with_limit(DEFAULT_MAX_PENDING_OPERATIONS) + .expect("default runtime operation limit should be valid"), + #[cfg(feature = "sqlite")] + sqlite_policy: SqlitePolicy::default(), + http_state: HttpState::default(), + async_bridge: None, + runtime_print_sink: None, + } + } + + /// Closes run-scoped host state between runs: pending operations are + /// cancelled, resources are closed, and the IO subsystem is recreated. + /// Host bindings, capability allow-lists, and the async bridge are + /// preserved (documented reusable state). + pub(crate) fn reset_for_reuse(&mut self) { + let _ = self + .runtime_operations + .cancel_all(CancellationReason::VmReset); + let _ = self + .runtime_resources + .close_all(CancellationReason::VmReset); + self.http_state.reset_for_reuse(); + } +} + +impl Default for HostRuntime { + fn default() -> Self { + Self::new() + } +} diff --git a/src/vm/instance.rs b/src/vm/instance.rs new file mode 100644 index 00000000..baecdfb0 --- /dev/null +++ b/src/vm/instance.rs @@ -0,0 +1,270 @@ +//! Interpreter instance state. +//! +//! [`Instance`] owns everything that describes one execution position inside a +//! program: the instruction pointer, operand stack, locals, frames, capture +//! cells, callable ownership, queued callback traffic, waiting/yield state, +//! and instance-only counters. It has no program reference of its own; the +//! immutable [`Program`](crate::bytecode::Program) and the backend +//! [`Engine`](super::engine::Engine) live beside it, so one program can drive +//! many independent instances and a reset only touches this struct. +//! +//! Lifecycle: [`Instance::new`] starts a fresh halted instance; [`Instance::reset`] +//! rewinds run state while keeping configuration and host bindings (owned by +//! the facade); [`Instance::drop_cleanup`] releases interpreter-owned values +//! with drop-contract accounting. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Weak}; + +use crate::bytecode::{CallableValue, Program, SharedCaptureCell, Value}; +use crate::vm::host::WaitingHostOp; +use crate::vm::map_iter::MapIteratorState; +use crate::vm::{DEFAULT_MAX_SCRIPT_CALL_DEPTH, VmYieldReason}; + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum FrameContinuation { + Halt, + ResumeBytecode { return_ip: usize }, + ReturnToHost, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct ExecutionFrame { + pub(crate) continuation: FrameContinuation, + pub(crate) operand_stack_base: usize, + pub(crate) local_base: usize, + pub(crate) local_count: usize, + pub(crate) prototype_id: Option, +} + +impl ExecutionFrame { + pub(crate) fn root(local_count: usize) -> Self { + Self { + continuation: FrameContinuation::Halt, + operand_stack_base: 0, + local_base: 0, + local_count, + prototype_id: None, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct QueuedCallable { + pub(crate) callable: Value, + pub(crate) args: Vec, + pub(crate) subscription: Option>, +} + +/// Interpreter-owned execution state. +/// +/// Thread safety: `Instance` is `!Sync` (it owns mutable interpreter state) +/// and is not shared; the VM facade owns exactly one instance. It is not +/// clonable: cloning would silently duplicate stack/frame/wait state. +pub(crate) struct Instance { + pub(crate) ip: usize, + pub(crate) stack: Vec, + pub(crate) locals: Vec, + pub(crate) capture_cells: HashMap, + pub(crate) shared_capture_slots: HashSet, + pub(crate) execution_frames: Vec, + pub(crate) active_local_base_cache: usize, + pub(crate) active_operand_stack_base_cache: usize, + pub(crate) call_depth: usize, + pub(crate) max_script_call_depth: usize, + pub(crate) host_return: Option, + pub(crate) queued_callables: VecDeque, + pub(crate) completed_callable_results: VecDeque, + pub(crate) owned_callables: Vec>, + pub(crate) callback_registry_flags: Vec>, + pub(crate) draining_queued_callables: bool, + pub(crate) shutdown: bool, + pub(super) waiting_host_op: Option, + pub(crate) last_yield_reason: Option, + pub(crate) map_iterators: Vec>>, + pub(crate) drop_contract_events_enabled: bool, + pub(crate) drop_contract_events: u64, + pub(crate) operand_hint_hit_count: u64, + pub(crate) operand_hint_miss_count: u64, + pub(crate) typed_builtin_fast_path_count: u64, + pub(crate) projection_fast_path_count: u64, + pub(crate) generic_builtin_call_count: u64, + pub(crate) scalar_superinstruction_count: u64, + pub(crate) local_type_hint_hit_count: u64, +} + +impl Instance { + /// Creates a halted instance positioned at program entry. + pub(crate) fn new(program: &Program) -> Self { + let local_count = program.local_count; + Self { + ip: 0, + stack: Vec::new(), + locals: vec![Value::Null; local_count], + capture_cells: HashMap::new(), + shared_capture_slots: HashSet::new(), + execution_frames: vec![ExecutionFrame::root(local_count)], + active_local_base_cache: 0, + active_operand_stack_base_cache: 0, + call_depth: 0, + max_script_call_depth: DEFAULT_MAX_SCRIPT_CALL_DEPTH, + host_return: None, + queued_callables: VecDeque::new(), + completed_callable_results: VecDeque::new(), + owned_callables: Vec::new(), + callback_registry_flags: Vec::new(), + draining_queued_callables: false, + shutdown: false, + waiting_host_op: None, + last_yield_reason: None, + map_iterators: Vec::new(), + drop_contract_events_enabled: false, + drop_contract_events: 0, + operand_hint_hit_count: 0, + operand_hint_miss_count: 0, + typed_builtin_fast_path_count: 0, + projection_fast_path_count: 0, + generic_builtin_call_count: 0, + scalar_superinstruction_count: 0, + local_type_hint_hit_count: 0, + } + } + + /// Rewinds run-scoped interpreter state for a fresh execution of the same + /// program. Host bindings, backend configuration, and compiled artifacts + /// (owned outside this struct) are preserved. + pub(crate) fn reset(&mut self, program: &Program) { + self.invalidate_callback_registries(); + self.ip = 0; + self.drop_contract_events = 0; + self.last_yield_reason = None; + self.clear_stack_with_drop_contract(); + self.capture_cells.clear(); + self.shared_capture_slots.clear(); + self.clear_locals_with_drop_contract(); + self.owned_callables.clear(); + self.locals.resize(program.local_count, Value::Null); + self.initialize_root_callable_bindings(program); + self.call_depth = 0; + self.execution_frames.clear(); + self.execution_frames + .push(ExecutionFrame::root(program.local_count)); + self.active_local_base_cache = 0; + self.active_operand_stack_base_cache = 0; + 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.map_iterators.clear(); + self.clear_interpreter_metrics(); + } + + /// 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.clear_stack_with_drop_contract(); + self.capture_cells.clear(); + self.shared_capture_slots.clear(); + self.clear_locals_with_drop_contract(); + } + + pub(crate) fn invalidate_callback_registries(&mut self) { + for active in self + .callback_registry_flags + .drain(..) + .filter_map(|flag| flag.upgrade()) + { + active.store(false, std::sync::atomic::Ordering::Release); + } + } + + pub(crate) fn register_callback_registry(&mut self, active: &Arc) { + self.callback_registry_flags.push(Arc::downgrade(active)); + } + + pub(crate) fn initialize_root_callable_bindings(&mut self, program: &Program) { + let bindings = program.root_callable_bindings.clone(); + for binding in bindings { + let Some(kind) = program + .callable_prototypes + .get(binding.prototype_id as usize) + .map(|prototype| prototype.kind) + else { + continue; + }; + if binding.local_slot as usize >= self.locals.len() { + continue; + } + let callable = Arc::new(CallableValue { + prototype_id: binding.prototype_id, + kind, + env: None, + }); + self.owned_callables.push(Arc::downgrade(&callable)); + self.locals[binding.local_slot as usize] = Value::Callable(callable); + } + } + + pub(crate) fn clear_interpreter_metrics(&mut self) { + self.operand_hint_hit_count = 0; + self.operand_hint_miss_count = 0; + self.typed_builtin_fast_path_count = 0; + self.projection_fast_path_count = 0; + self.generic_builtin_call_count = 0; + self.scalar_superinstruction_count = 0; + self.local_type_hint_hit_count = 0; + } + + pub(crate) fn clear_stack_with_drop_contract(&mut self) { + let drained = self.stack.drain(..).collect::>(); + for value in drained { + self.drop_value_with_contract(value); + } + } + + pub(crate) fn clear_locals_with_drop_contract(&mut self) { + for slot in 0..self.locals.len() { + let previous = std::mem::replace(&mut self.locals[slot], Value::Null); + self.drop_value_with_contract(previous); + } + } + + pub(crate) fn drop_value_with_contract(&mut self, value: Value) { + if self.drop_contract_events_enabled { + self.count_value_drop_contract(&value); + } + } + + pub(crate) fn count_value_drop_contract(&mut self, value: &Value) { + match value { + Value::Null => {} + Value::Array(values) => { + self.drop_contract_events = self.drop_contract_events.saturating_add(1); + for item in values.iter() { + self.count_value_drop_contract(item); + } + } + Value::Map(entries) => { + self.drop_contract_events = self.drop_contract_events.saturating_add(1); + for (key, value) in entries.iter() { + self.count_value_drop_contract(key); + self.count_value_drop_contract(value); + } + } + Value::Int(_) + | Value::Float(_) + | Value::Bool(_) + | Value::String(_) + | Value::Bytes(_) + | Value::Callable(_) => { + self.drop_contract_events = self.drop_contract_events.saturating_add(1); + } + } + } +} diff --git a/src/vm/jit/diagnostics.rs b/src/vm/jit/diagnostics.rs index 9fe0dafe..47eb71e0 100644 --- a/src/vm/jit/diagnostics.rs +++ b/src/vm/jit/diagnostics.rs @@ -3,11 +3,12 @@ use super::{JitMetrics, JitSnapshot, native}; impl Vm { pub(super) fn jit_diagnostics_snapshot(&self) -> JitSnapshot { - self.jit.snapshot(self.jit_diagnostics_metrics()) + self.engine.jit.snapshot(self.jit_diagnostics_metrics()) } pub(super) fn jit_diagnostics_dump(&self, include_machine_code: bool) -> String { let mut out = self + .engine .jit .dump_text(self.program.debug.as_ref(), self.jit_diagnostics_metrics()); out.push_str(&format!( @@ -16,35 +17,36 @@ impl Vm { )); out.push_str(&format!( " native trace executions: {}\n", - self.native_trace_exec_count + self.engine.native_trace_exec_count )); out.push_str(&format!( " native trace handoffs: {}\n", - self.jit_native_link_handoff_count + self.engine.jit_native_link_handoff_count )); out.push_str(&format!( " native region entries: {}\n", - self.jit_native_region_entry_count + self.engine.jit_native_region_entry_count )); out.push_str(&format!( " native internal region edges: {}\n", - self.jit_native_region_edge_count + self.engine.jit_native_region_edge_count )); out.push_str(&format!( " native direct side links: {}\n", - self.jit_native_direct_link_count + self.engine.jit_native_direct_link_count )); out.push_str(&format!( " native compile time: {} ns (regions={} ns)\n", - self.jit_native_compile_time_ns, self.jit_native_region_compile_time_ns + self.engine.jit_native_compile_time_ns, self.engine.jit_native_region_compile_time_ns )); out.push_str(&format!( " native code bytes: {} (regions={})\n", self.jit_native_code_bytes(), self.jit_native_region_code_bytes() )); - if self.jit_native_bridge_stats_enabled { + if self.engine.jit_native_bridge_stats_enabled { let mut bridge_entries: Vec<(&'static str, u64)> = self + .engine .jit_native_bridge_counts .iter() .map(|(name, count)| (*name, *count)) @@ -62,14 +64,14 @@ impl Vm { out.push_str(&format!(" bridge {}: {}\n", name, count)); } } - let native_trace_count = self.native_traces.iter().flatten().count(); + let native_trace_count = self.engine.native_traces.iter().flatten().count(); if native_trace_count == 0 { out.push_str(" native traces: 0\n"); return out; } out.push_str(&format!(" native traces: {}\n", native_trace_count)); - for (id, native) in self.native_traces.iter().enumerate() { + for (id, native) in self.engine.native_traces.iter().enumerate() { if let Some(native) = native { out.push_str(&format!( " native trace#{} entry=0x{:X} code_bytes={} lowering={}\n", @@ -109,10 +111,10 @@ impl Vm { JitMetrics { boxed_load_site_count: 0, boxed_store_site_count: 0, - trace_exit_count: self.jit_trace_exit_count, - native_loop_back_count: self.jit_native_loop_back_count, - helper_fallback_count: self.jit_helper_fallback_count, - native_trace_exec_count: self.native_trace_exec_count, + trace_exit_count: self.engine.jit_trace_exit_count, + native_loop_back_count: self.engine.jit_native_loop_back_count, + helper_fallback_count: self.engine.jit_helper_fallback_count, + native_trace_exec_count: self.engine.native_trace_exec_count, script_call_observations: 0, monomorphic_call_sites: 0, polymorphic_call_sites: 0, diff --git a/src/vm/jit/runtime.rs b/src/vm/jit/runtime.rs index 751ef231..e4d946a0 100644 --- a/src/vm/jit/runtime.rs +++ b/src/vm/jit/runtime.rs @@ -257,20 +257,27 @@ pub(crate) extern "C" fn pd_vm_native_resume_linked_trace(vm: *mut Vm) -> i32 { return native::STATUS_ERROR; }; - if vm_ref.jit_native_link_dispatch_depth > 0 { + if vm_ref.engine.jit_native_link_dispatch_depth > 0 { return native::STATUS_TRACE_EXIT; } - vm_ref.jit_native_link_dispatch_depth = vm_ref.jit_native_link_dispatch_depth.saturating_add(1); + vm_ref.engine.jit_native_link_dispatch_depth = vm_ref + .engine + .jit_native_link_dispatch_depth + .saturating_add(1); match vm_ref.continue_linked_native_trace_from_exit() { Ok(status) => { - vm_ref.jit_native_link_dispatch_depth = - vm_ref.jit_native_link_dispatch_depth.saturating_sub(1); + vm_ref.engine.jit_native_link_dispatch_depth = vm_ref + .engine + .jit_native_link_dispatch_depth + .saturating_sub(1); status } Err(err) => { - vm_ref.jit_native_link_dispatch_depth = - vm_ref.jit_native_link_dispatch_depth.saturating_sub(1); + vm_ref.engine.jit_native_link_dispatch_depth = vm_ref + .engine + .jit_native_link_dispatch_depth + .saturating_sub(1); native::store_bridge_error(err); native::STATUS_ERROR } @@ -283,9 +290,9 @@ impl Vm { return None; } let entry_callable_prototypes = self.active_local_callable_prototypes(); - self.jit.compiled_trace_for_entry_with_callables( + self.engine.jit.compiled_trace_for_entry_with_callables( self.active_frame_key(), - self.ip, + self.instance.ip, self.active_operand_stack_len(), entry_callable_prototypes.as_deref(), ) @@ -299,21 +306,21 @@ impl Vm { all(target_arch = "aarch64", any(target_os = "linux", target_os = "macos")) ))] fn continue_linked_native_trace_from_exit(&mut self) -> VmResult { - self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); + self.engine.jit_trace_exit_count = self.engine.jit_trace_exit_count.saturating_add(1); let mut current_trace_id = { - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); if next_trace_id.is_none() && !self.active_frame_has_shared_capture_cells() - && !self.jit.callable_frame_is_blocked(frame_key) + && !self.engine.jit.callable_frame_is_blocked(frame_key) { let entry_local_types = (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - next_trace_id = self.jit.observe_exit_entry_with_local_types( + next_trace_id = self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -352,15 +359,16 @@ impl Vm { loop { native::clear_bridge_error(); - let region_edges_before = self.jit_native_region_edge_count; - let direct_links_before = self.jit_native_direct_link_count; + let region_edges_before = self.engine.jit_native_region_edge_count; + let direct_links_before = self.engine.jit_native_direct_link_count; let status = unsafe { entry(self as *mut Vm) }; - self.native_trace_exec_count = self.native_trace_exec_count.saturating_add(1); + self.engine.native_trace_exec_count = + self.engine.native_trace_exec_count.saturating_add(1); if !is_region - && self.jit_native_active_direct_trace_id != usize::MAX - && self.jit_native_active_direct_trace_id != current_trace_id + && self.engine.jit_native_active_direct_trace_id != usize::MAX + && self.engine.jit_native_active_direct_trace_id != current_trace_id { - current_trace_id = self.jit_native_active_direct_trace_id; + current_trace_id = self.engine.jit_native_active_direct_trace_id; let state = self.native_trace_state(current_trace_id)?; entry = state.0; root_ip = state.1; @@ -371,13 +379,15 @@ impl Vm { } self.record_native_direct_escape(status, direct_links_before); if is_region { - self.jit_native_region_entry_count = - self.jit_native_region_entry_count.saturating_add(1); - if self.jit_native_region_edge_count > region_edges_before { - self.jit.record_native_region_progress(current_trace_id); + self.engine.jit_native_region_entry_count = + self.engine.jit_native_region_entry_count.saturating_add(1); + if self.engine.jit_native_region_edge_count > region_edges_before { + self.engine + .jit + .record_native_region_progress(current_trace_id); } } - self.jit.mark_trace_executed(current_trace_id); + self.engine.jit.mark_trace_executed(current_trace_id); let mut trace_exit_key = None; let mut instruction_failure_exit = false; let status = if let Some(exit_id) = native::decode_jit_trace_exit_status(status) { @@ -393,8 +403,9 @@ impl Vm { exit_id: SsaExitId::new(exit_id), } }; - instruction_failure_exit = self.jit.trace_exit_is_instruction_failure(key); - self.jit + instruction_failure_exit = self.engine.jit.trace_exit_is_instruction_failure(key); + self.engine + .jit .record_trace_exit(key) .map_err(|err| VmError::JitNative(err.message()))?; trace_exit_key = Some(key); @@ -445,37 +456,39 @@ impl Vm { return Ok(native::STATUS_LINKED_CONTINUE); } native::STATUS_TRACE_EXIT => { - self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); + self.engine.jit_trace_exit_count = + self.engine.jit_trace_exit_count.saturating_add(1); if instruction_failure_exit { return Ok(native::STATUS_LINKED_CONTINUE); } if !has_yielding_call && terminal == JitTraceTerminal::LoopBack - && self.ip == root_ip + && self.instance.ip == root_ip { - self.jit.record_native_loop_back(current_trace_id); - self.jit_native_loop_back_count = - self.jit_native_loop_back_count.saturating_add(1); + self.engine.jit.record_native_loop_back(current_trace_id); + self.engine.jit_native_loop_back_count = + self.engine.jit_native_loop_back_count.saturating_add(1); continue; } - if self.jit.record_native_side_exit(current_trace_id) - && !self.jit_native_direct_links_enabled + if self.engine.jit.record_native_side_exit(current_trace_id) + && !self.engine.jit_native_direct_links_enabled { self.block_jit_callable_frame(current_trace_id); return Ok(native::STATUS_LINKED_CONTINUE); } if !has_yielding_call && !self.active_frame_has_shared_capture_cells() { - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); - if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) + if next_trace_id.is_none() + && !self.engine.jit.callable_frame_is_blocked(frame_key) { let entry_local_types = (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - next_trace_id = self.jit.observe_exit_entry_with_local_types( + next_trace_id = self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -534,19 +547,19 @@ impl Vm { } fn active_native_interrupt_settings(&self) -> Option { - match self.interrupt_mode { + match self.run_ctx.interrupt_mode { super::super::InterruptMode::None => None, super::super::InterruptMode::Fuel => Some(native::NativeInterruptSettings::fuel( - self.fuel_check_interval, + self.run_ctx.fuel_check_interval, )), super::super::InterruptMode::Epoch => Some(native::NativeInterruptSettings::epoch( - self.fuel_check_interval, + self.run_ctx.fuel_check_interval, )), } } fn clear_native_direct_links(&self) { - for native in self.native_traces.iter().flatten() { + for native in self.engine.native_traces.iter().flatten() { for slot in native.direct_slots.values() { slot.clear(); } @@ -554,15 +567,16 @@ impl Vm { } fn record_native_direct_escape(&mut self, _status: i32, direct_links_before: u64) { - if !self.jit_native_direct_links_enabled - || self.jit_native_direct_link_count == direct_links_before + if !self.engine.jit_native_direct_links_enabled + || self.engine.jit_native_direct_link_count == direct_links_before { return; } - self.jit_native_direct_escape_streak = 0; - if self.jit_native_active_direct_trace_id != usize::MAX { - self.jit - .record_native_loop_back(self.jit_native_active_direct_trace_id); + self.engine.jit_native_direct_escape_streak = 0; + if self.engine.jit_native_active_direct_trace_id != usize::MAX { + self.engine + .jit + .record_native_loop_back(self.engine.jit_native_active_direct_trace_id); } } @@ -571,7 +585,9 @@ impl Vm { key: TraceExitKey, child_trace_id: usize, ) -> VmResult<()> { - if !self.jit_native_direct_links_enabled || self.jit_native_direct_region_fallback { + if !self.engine.jit_native_direct_links_enabled + || self.engine.jit_native_direct_region_fallback + { return Ok(()); } self.publish_native_direct_slot(key.parent_trace_id, key.exit_id.raw(), child_trace_id) @@ -590,15 +606,21 @@ impl Vm { slot_id: u32, child_trace_id: usize, ) -> VmResult<()> { - if self.jit.trace_has_entry_callable_guards(child_trace_id) { + if self + .engine + .jit + .trace_has_entry_callable_guards(child_trace_id) + { return Ok(()); } - if !self.jit_native_direct_cross_frame_enabled { + if !self.engine.jit_native_direct_cross_frame_enabled { let parent_frame_key = self + .engine .jit .trace_clone(parent_trace_id) .map(|trace| trace.frame_key); let child_frame_key = self + .engine .jit .trace_clone(child_trace_id) .map(|trace| trace.frame_key); @@ -608,6 +630,7 @@ impl Vm { } self.ensure_native_trace(child_trace_id, native::NativeCompileProfile::Jit)?; let child_entry = self + .engine .native_traces .get(child_trace_id) .and_then(Option::as_ref) @@ -616,6 +639,7 @@ impl Vm { })? .tail_entry as *const u8; let Some(slot) = self + .engine .native_traces .get(parent_trace_id) .and_then(Option::as_ref) @@ -652,28 +676,34 @@ impl Vm { all(target_arch = "aarch64", any(target_os = "linux", target_os = "macos")) ))] fn maybe_publish_native_region(&mut self, key: TraceExitKey, child_trace_id: usize) { - if self.jit_native_direct_links_enabled && !self.jit_native_direct_region_fallback { + if self.engine.jit_native_direct_links_enabled + && !self.engine.jit_native_direct_region_fallback + { return; } if self + .engine .jit .trace_has_entry_callable_guards(key.parent_trace_id) - || self.jit.trace_has_entry_callable_guards(child_trace_id) + || self + .engine + .jit + .trace_has_entry_callable_guards(child_trace_id) { return; } - let Some(candidate) = self.jit.region_candidate(key, child_trace_id) else { + let Some(candidate) = self.engine.jit.region_candidate(key, child_trace_id) else { return; }; - if candidate.generation != self.jit.region_generation() { + if candidate.generation != self.engine.jit.region_generation() { return; } - let Some(parent) = self.jit.trace_clone(key.parent_trace_id) else { - self.jit.record_region_compile_failure(&candidate); + let Some(parent) = self.engine.jit.trace_clone(key.parent_trace_id) else { + self.engine.jit.record_region_compile_failure(&candidate); return; }; - let Some(child) = self.jit.trace_clone(child_trace_id) else { - self.jit.record_region_compile_failure(&candidate); + let Some(child) = self.engine.jit.trace_clone(child_trace_id) else { + self.engine.jit.record_region_compile_failure(&candidate); return; }; let back_import = scalar_cycle_import(&candidate.import) @@ -684,7 +714,8 @@ impl Vm { .iter() .filter(|exit| exit.exit_ip == parent.root_ip) .find_map(|exit| { - self.jit + self.engine + .jit .side_trace_import(child.id, exit.id, parent.id) .ok() }) @@ -699,7 +730,7 @@ impl Vm { ) { Ok(fused) => fused, Err(_) => { - self.jit.record_region_compile_failure(&candidate); + self.engine.jit.record_region_compile_failure(&candidate); return; } }; @@ -713,13 +744,14 @@ impl Vm { compile_profile, drop_contract_events_enabled, ); - self.jit_native_region_compile_time_ns = self + self.engine.jit_native_region_compile_time_ns = self + .engine .jit_native_region_compile_time_ns .saturating_add(elapsed_ns(compile_started)); let compiled = match compile_result { Ok(compiled) => compiled, Err(_) => { - self.jit.record_region_compile_failure(&candidate); + self.engine.jit.record_region_compile_failure(&candidate); return; } }; @@ -741,37 +773,38 @@ impl Vm { exit_keys: Arc::new(fused.exit_keys), }; let Some(parent_native) = self + .engine .native_traces .get_mut(key.parent_trace_id) .and_then(Option::as_mut) else { - self.jit.record_region_compile_failure(&candidate); + self.engine.jit.record_region_compile_failure(&candidate); return; }; - if !self.jit.publish_region(&candidate) { + if !self.engine.jit.publish_region(&candidate) { return; } parent_native.region = Some(region); } fn clear_native_region_owners(&mut self) { - for native in self.native_traces.iter_mut().flatten() { + for native in self.engine.native_traces.iter_mut().flatten() { native.region = None; } } pub(crate) fn disconnect_native_regions(&mut self) { - self.jit.invalidate_regions(); + self.engine.jit.invalidate_regions(); self.clear_native_region_owners(); } fn block_jit_trace(&mut self, trace_id: usize) { - self.jit.block_trace(trace_id); + self.engine.jit.block_trace(trace_id); self.clear_native_region_owners(); } fn block_jit_callable_frame(&mut self, trace_id: usize) { - self.jit.block_callable_frame(trace_id); + self.engine.jit.block_callable_frame(trace_id); self.clear_native_region_owners(); } @@ -780,26 +813,26 @@ impl Vm { self.ensure_program_cache_key(); } self.clear_native_direct_links(); - self.native_traces.clear(); - self.native_trace_exec_count = 0; - self.jit_native_region_entry_count = 0; - self.jit_native_region_edge_count = 0; - self.jit_native_direct_link_count = 0; - self.jit_native_active_direct_trace_id = usize::MAX; - self.jit_native_direct_escape_streak = 0; - self.jit_native_direct_region_fallback = false; - self.jit_native_compile_time_ns = 0; - self.jit_native_region_compile_time_ns = 0; - self.jit_trace_exit_count = 0; - self.jit_native_loop_back_count = 0; - self.jit_native_link_handoff_count = 0; - self.jit_native_link_dispatch_depth = 0; - self.jit_helper_fallback_count = 0; - self.jit.set_config(config); + self.engine.native_traces.clear(); + self.engine.native_trace_exec_count = 0; + self.engine.jit_native_region_entry_count = 0; + self.engine.jit_native_region_edge_count = 0; + self.engine.jit_native_direct_link_count = 0; + self.engine.jit_native_active_direct_trace_id = usize::MAX; + self.engine.jit_native_direct_escape_streak = 0; + self.engine.jit_native_direct_region_fallback = false; + self.engine.jit_native_compile_time_ns = 0; + self.engine.jit_native_region_compile_time_ns = 0; + self.engine.jit_trace_exit_count = 0; + self.engine.jit_native_loop_back_count = 0; + self.engine.jit_native_link_handoff_count = 0; + self.engine.jit_native_link_dispatch_depth = 0; + self.engine.jit_helper_fallback_count = 0; + self.engine.jit.set_config(config); } pub fn jit_config(&self) -> &super::JitConfig { - self.jit.config() + self.engine.jit.config() } pub fn jit_snapshot(&self) -> super::JitSnapshot { @@ -807,15 +840,16 @@ impl Vm { } pub fn jit_exit_profiles(&self) -> Vec { - self.jit.exit_profiles() + self.engine.jit.exit_profiles() } pub fn jit_call_site_profiles(&self) -> Vec { - self.jit.call_site_profiles() + self.engine.jit.call_site_profiles() } pub fn jit_native_code_bytes(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .map(|native| native.code.len()) @@ -823,7 +857,8 @@ impl Vm { } pub fn jit_native_region_code_bytes(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .filter_map(|native| native.region.as_ref()) @@ -832,11 +867,11 @@ impl Vm { } pub fn jit_native_compile_time_ns(&self) -> u64 { - self.jit_native_compile_time_ns + self.engine.jit_native_compile_time_ns } pub fn jit_native_region_compile_time_ns(&self) -> u64 { - self.jit_native_region_compile_time_ns + self.engine.jit_native_region_compile_time_ns } pub fn dump_jit_info(&self) -> String { @@ -909,15 +944,16 @@ impl Vm { ) = self.native_trace_state(current_trace_id)?; native::clear_bridge_error(); loop { - let region_edges_before = self.jit_native_region_edge_count; - let direct_links_before = self.jit_native_direct_link_count; + let region_edges_before = self.engine.jit_native_region_edge_count; + let direct_links_before = self.engine.jit_native_direct_link_count; let status = unsafe { entry(self as *mut Vm) }; - self.native_trace_exec_count = self.native_trace_exec_count.saturating_add(1); + self.engine.native_trace_exec_count = + self.engine.native_trace_exec_count.saturating_add(1); if !is_region - && self.jit_native_active_direct_trace_id != usize::MAX - && self.jit_native_active_direct_trace_id != current_trace_id + && self.engine.jit_native_active_direct_trace_id != usize::MAX + && self.engine.jit_native_active_direct_trace_id != current_trace_id { - current_trace_id = self.jit_native_active_direct_trace_id; + current_trace_id = self.engine.jit_native_active_direct_trace_id; let state = self.native_trace_state(current_trace_id)?; entry = state.0; root_ip = state.1; @@ -928,13 +964,15 @@ impl Vm { } self.record_native_direct_escape(status, direct_links_before); if is_region { - self.jit_native_region_entry_count = - self.jit_native_region_entry_count.saturating_add(1); - if self.jit_native_region_edge_count > region_edges_before { - self.jit.record_native_region_progress(current_trace_id); + self.engine.jit_native_region_entry_count = + self.engine.jit_native_region_entry_count.saturating_add(1); + if self.engine.jit_native_region_edge_count > region_edges_before { + self.engine + .jit + .record_native_region_progress(current_trace_id); } } - self.jit.mark_trace_executed(current_trace_id); + self.engine.jit.mark_trace_executed(current_trace_id); let mut trace_exit_key = None; let mut instruction_failure_exit = false; let status = if let Some(exit_id) = native::decode_jit_trace_exit_status(status) { @@ -950,8 +988,9 @@ impl Vm { exit_id: SsaExitId::new(exit_id), } }; - instruction_failure_exit = self.jit.trace_exit_is_instruction_failure(key); - self.jit + instruction_failure_exit = self.engine.jit.trace_exit_is_instruction_failure(key); + self.engine + .jit .record_trace_exit(key) .map_err(|err| VmError::JitNative(err.message()))?; trace_exit_key = Some(key); @@ -1017,13 +1056,19 @@ impl Vm { return Ok(ExecOutcome::Continue); } native::STATUS_TRACE_EXIT => { - self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); + self.engine.jit_trace_exit_count = + self.engine.jit_trace_exit_count.saturating_add(1); if instruction_failure_exit { return Ok(ExecOutcome::Continue); } - if self.jit.trace_clone(current_trace_id).is_some_and(|trace| { - trace.op_names.last().map(String::as_str) == Some("callable_boundary") - }) { + if self + .engine + .jit + .trace_clone(current_trace_id) + .is_some_and(|trace| { + trace.op_names.last().map(String::as_str) == Some("callable_boundary") + }) + { self.block_jit_trace(current_trace_id); return Ok(ExecOutcome::Continue); } @@ -1031,25 +1076,26 @@ impl Vm { // calls, keep executing in native mode without bouncing through the interpreter. if !has_yielding_call && terminal == JitTraceTerminal::LoopBack - && self.ip == root_ip + && self.instance.ip == root_ip { - self.jit.record_native_loop_back(current_trace_id); - self.jit_native_loop_back_count = - self.jit_native_loop_back_count.saturating_add(1); + self.engine.jit.record_native_loop_back(current_trace_id); + self.engine.jit_native_loop_back_count = + self.engine.jit_native_loop_back_count.saturating_add(1); continue; } - if self.jit.record_native_side_exit(current_trace_id) - && !self.jit_native_direct_links_enabled + if self.engine.jit.record_native_side_exit(current_trace_id) + && !self.engine.jit_native_direct_links_enabled { self.block_jit_callable_frame(current_trace_id); return Ok(ExecOutcome::Continue); } if !has_yielding_call && !self.active_frame_has_shared_capture_cells() { - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); - if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) + if next_trace_id.is_none() + && !self.engine.jit.callable_frame_is_blocked(frame_key) { next_trace_id = { let entry_local_types = (frame_key != ROOT_FRAME_KEY) @@ -1057,7 +1103,7 @@ impl Vm { let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - self.jit.observe_exit_entry_with_local_types( + self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -1121,17 +1167,19 @@ impl Vm { if self.active_frame_has_shared_capture_cells() { return Ok(ExecOutcome::Continue); } - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); - if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) { + if next_trace_id.is_none() + && !self.engine.jit.callable_frame_is_blocked(frame_key) + { next_trace_id = { let entry_local_types = (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - self.jit.observe_exit_entry_with_local_types( + self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -1186,26 +1234,31 @@ impl Vm { return Ok(ExecOutcome::Continue); } native::STATUS_YIELDED => { - self.last_yield_reason = Some(super::super::VmYieldReason::Host); + self.instance.last_yield_reason = Some(super::super::VmYieldReason::Host); return Ok(ExecOutcome::Yielded); } native::STATUS_WAITING => { - let op_id = self.waiting_host_op.map(|op| op.op_id).ok_or_else(|| { - VmError::JitNative( - "native call bridge reported waiting without a pending op".to_string(), - ) - })?; + let op_id = self + .instance + .waiting_host_op + .map(|op| op.op_id) + .ok_or_else(|| { + VmError::JitNative( + "native call bridge reported waiting without a pending op" + .to_string(), + ) + })?; return Ok(ExecOutcome::Waiting(op_id)); } native::STATUS_OUT_OF_FUEL => { - return match self.interrupt_mode { + return match self.run_ctx.interrupt_mode { super::super::InterruptMode::Fuel => Err(VmError::OutOfFuel { - needed: u64::from(self.fuel_check_interval), - remaining: self.fuel_remaining, + needed: u64::from(self.run_ctx.fuel_check_interval), + remaining: self.run_ctx.fuel_remaining, }), super::super::InterruptMode::Epoch => Err(VmError::EpochDeadlineReached { current: self.current_epoch(), - deadline: self.epoch_deadline, + deadline: self.run_ctx.epoch_deadline, }), super::super::InterruptMode::None => Err(VmError::JitNative( "native interruption checkpoint fired while interruption was disabled" @@ -1215,19 +1268,20 @@ impl Vm { } native::STATUS_ERROR => { let err = native::take_bridge_error().unwrap_or_else(|| { - let trace_meta = self.jit.trace_clone(current_trace_id).map(|trace| { - format!( - "trace_id={} root_ip={} terminal={:?} ops={}", - trace.id, - trace.root_ip, - trace.terminal, - trace.op_names.len() - ) - }); + let trace_meta = + self.engine.jit.trace_clone(current_trace_id).map(|trace| { + format!( + "trace_id={} root_ip={} terminal={:?} ops={}", + trace.id, + trace.root_ip, + trace.terminal, + trace.op_names.len() + ) + }); VmError::JitNative(format!( "jit bridge reported failure without VmError (ip={} stack_len={} {})", - self.ip, - self.stack.len(), + self.instance.ip, + self.instance.stack.len(), trace_meta.unwrap_or_else(|| "trace=".to_string()) )) }); @@ -1252,6 +1306,7 @@ impl Vm { ))] fn native_trace_state(&self, trace_id: usize) -> VmResult { let native = self + .engine .native_traces .get(trace_id) .and_then(Option::as_ref) @@ -1259,7 +1314,7 @@ impl Vm { VmError::JitNative(format!("native trace entry for id {} missing", trace_id)) })?; if let Some(region) = native.region.as_ref().filter(|region| { - self.jit.published_region().is_some_and(|published| { + self.engine.jit.published_region().is_some_and(|published| { published.generation == region.generation && published.key == region.key && published.child_trace_id == region.child_trace_id @@ -1300,10 +1355,10 @@ impl Vm { trace_id: usize, compile_profile: native::NativeCompileProfile, ) -> Option { - let native = self.native_traces.get(trace_id)?.as_ref()?; + let native = self.engine.native_traces.get(trace_id)?.as_ref()?; (native.interrupt_settings == self.active_native_interrupt_settings() && compile_profile_satisfies(native.compile_profile, compile_profile) - && native.drop_contract_events_enabled == self.drop_contract_events_enabled) + && native.drop_contract_events_enabled == self.instance.drop_contract_events_enabled) .then(|| self.native_trace_state(trace_id).ok()) .flatten() } @@ -1337,7 +1392,11 @@ impl Vm { compile_profile: native::NativeCompileProfile, interrupt_settings: Option, ) -> VmResult<()> { - if let Some(native) = self.native_traces.get(trace_id).and_then(Option::as_ref) + if let Some(native) = self + .engine + .native_traces + .get(trace_id) + .and_then(Option::as_ref) && native.interrupt_settings == interrupt_settings && compile_profile_satisfies(native.compile_profile, compile_profile) && native.drop_contract_events_enabled == self.drop_contract_events_enabled() @@ -1345,6 +1404,7 @@ impl Vm { return Ok(()); } if self + .engine .native_traces .get(trace_id) .and_then(Option::as_ref) @@ -1353,12 +1413,12 @@ impl Vm { self.disconnect_native_regions(); } self.clear_native_direct_links(); - if let Some(slot) = self.native_traces.get_mut(trace_id) { + if let Some(slot) = self.engine.native_traces.get_mut(trace_id) { *slot = None; } let program_cache_key = self.ensure_program_cache_key(); - let trace = self.jit.trace_clone(trace_id).ok_or_else(|| { + let trace = self.engine.jit.trace_clone(trace_id).ok_or_else(|| { VmError::JitNative(format!("trace {} missing for native compile", trace_id)) })?; let drop_contract_events_enabled = self.drop_contract_events_enabled(); @@ -1393,10 +1453,10 @@ impl Vm { .collect(); let mut code = cached.code.to_vec(); code.extend_from_slice(&dispatcher.code); - if self.native_traces.len() <= trace_id { - self.native_traces.resize_with(trace_id + 1, || None); + if self.engine.native_traces.len() <= trace_id { + self.engine.native_traces.resize_with(trace_id + 1, || None); } - self.native_traces[trace_id] = Some(NativeTrace { + self.engine.native_traces[trace_id] = Some(NativeTrace { _keepalive: cached.keepalive, _direct_keepalives: direct_keepalives, entry, @@ -1423,7 +1483,8 @@ impl Vm { compile_profile, drop_contract_events_enabled, ); - self.jit_native_compile_time_ns = self + self.engine.jit_native_compile_time_ns = self + .engine .jit_native_compile_time_ns .saturating_add(elapsed_ns(compile_started)); let compiled = compile_result?; @@ -1463,10 +1524,10 @@ impl Vm { let mut code = compiled.code; code.extend_from_slice(&dispatcher.code); let code = Arc::<[u8]>::from(code.into_boxed_slice()); - if self.native_traces.len() <= trace_id { - self.native_traces.resize_with(trace_id + 1, || None); + if self.engine.native_traces.len() <= trace_id { + self.engine.native_traces.resize_with(trace_id + 1, || None); } - self.native_traces[trace_id] = Some(NativeTrace { + self.engine.native_traces[trace_id] = Some(NativeTrace { _keepalive: keepalive, _direct_keepalives: direct_keepalives, entry, @@ -1487,21 +1548,24 @@ impl Vm { } pub fn jit_native_trace_count(&self) -> usize { - self.native_traces.iter().flatten().count() + self.engine.native_traces.iter().flatten().count() } pub fn jit_native_exec_count(&self) -> u64 { - self.native_trace_exec_count + self.engine.native_trace_exec_count } pub(crate) fn jit_native_inherited_target(&self) -> usize { - if !self.jit_native_direct_links_enabled || self.active_frame_has_shared_capture_cells() { + if !self.engine.jit_native_direct_links_enabled + || self.active_frame_has_shared_capture_cells() + { return 0; } let Some(trace_id) = self.compiled_trace_for_active_entry() else { return 0; }; - self.native_traces + self.engine + .native_traces .get(trace_id) .and_then(Option::as_ref) .map(|native| native.tail_entry as usize) @@ -1510,24 +1574,25 @@ impl Vm { pub fn set_jit_native_direct_links_enabled(&mut self, enabled: bool) { let cross_frame_enabled = enabled; - if self.jit_native_direct_links_enabled == enabled - && self.jit_native_direct_cross_frame_enabled == cross_frame_enabled + if self.engine.jit_native_direct_links_enabled == enabled + && self.engine.jit_native_direct_cross_frame_enabled == cross_frame_enabled { return; } self.clear_native_direct_links(); self.disconnect_native_regions(); - self.native_traces.clear(); - self.jit_native_direct_links_enabled = enabled; - self.jit_native_direct_cross_frame_enabled = cross_frame_enabled; - self.jit_native_direct_link_count = 0; - self.jit_native_active_direct_trace_id = usize::MAX; - self.jit_native_direct_escape_streak = 0; - self.jit_native_direct_region_fallback = false; + self.engine.native_traces.clear(); + self.engine.jit_native_direct_links_enabled = enabled; + self.engine.jit_native_direct_cross_frame_enabled = cross_frame_enabled; + self.engine.jit_native_direct_link_count = 0; + self.engine.jit_native_active_direct_trace_id = usize::MAX; + self.engine.jit_native_direct_escape_streak = 0; + self.engine.jit_native_direct_region_fallback = false; } pub fn jit_native_region_count(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .filter(|native| native.region.is_some()) @@ -1535,19 +1600,20 @@ impl Vm { } pub fn jit_native_region_entry_count(&self) -> u64 { - self.jit_native_region_entry_count + self.engine.jit_native_region_entry_count } pub fn jit_native_internal_region_edge_count(&self) -> u64 { - self.jit_native_region_edge_count + self.engine.jit_native_region_edge_count } pub fn jit_native_direct_link_count(&self) -> u64 { - self.jit_native_direct_link_count + self.engine.jit_native_direct_link_count } pub fn jit_native_active_direct_link_slot_count(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .flat_map(|native| native.direct_slots.values()) @@ -1556,19 +1622,21 @@ impl Vm { } pub fn jit_helper_fallback_count(&self) -> u64 { - self.jit_helper_fallback_count + self.engine.jit_helper_fallback_count } pub fn jit_native_link_handoff_count(&self) -> u64 { - self.jit_native_link_handoff_count + self.engine.jit_native_link_handoff_count } fn record_jit_helper_fallback(&mut self) { - self.jit_helper_fallback_count = self.jit_helper_fallback_count.saturating_add(1); + self.engine.jit_helper_fallback_count = + self.engine.jit_helper_fallback_count.saturating_add(1); } fn record_jit_link_handoff(&mut self) { - self.jit_native_link_handoff_count = self.jit_native_link_handoff_count.saturating_add(1); + self.engine.jit_native_link_handoff_count = + self.engine.jit_native_link_handoff_count.saturating_add(1); } } diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 295ff544..0709c6cd 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -1,21 +1,27 @@ -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Mutex}; pub(crate) mod aot; pub mod diagnostics; +mod engine; mod epoch; mod fuel; mod host; +mod host_runtime; +mod instance; pub(crate) mod jit; mod map_iter; pub(crate) mod native; +pub mod program; +mod run_context; mod store; mod superinstructions; #[cfg(test)] mod tests; pub use self::aot::AotArtifactError; +use self::engine::Engine; pub use self::epoch::{EpochCheckpoint, EpochHandle}; pub use self::fuel::FuelCheckpoint; pub use self::host::{ @@ -23,7 +29,54 @@ pub use self::host::{ HostFunctionRegistry, HostOpId, HostStackFunction, StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, }; -use self::host::{HostCallExecOutcome, VmHostFunction, WaitingHostOp}; +use self::host::{HostCallExecOutcome, VmHostFunction}; +use self::host_runtime::HostRuntime; +use self::instance::{ExecutionFrame, FrameContinuation, Instance, QueuedCallable}; +use self::run_context::{InterruptMode, RunContext}; +pub use crate::builtins::runtime::cancellation::CancellationReason; + +#[cfg(feature = "sqlite")] +#[derive(Clone, Copy, Debug)] +pub struct SqliteLimits { + pub max_connections: usize, + pub max_statements: usize, + pub max_rows: usize, + pub max_columns: usize, + pub max_result_bytes: usize, + pub max_statement_bytes: usize, + pub max_parameters: usize, + pub max_parameter_bytes: usize, + pub max_pending_operations: usize, + pub max_transaction_ms: u64, + pub busy_timeout_ms: u64, +} + +#[cfg(feature = "sqlite")] +impl Default for SqliteLimits { + fn default() -> Self { + Self { + max_connections: 16, + max_statements: 128, + max_rows: 1_000, + max_columns: 128, + max_result_bytes: 4 * 1024 * 1024, + max_statement_bytes: 1024 * 1024, + max_parameters: 128, + max_parameter_bytes: 1024 * 1024, + max_pending_operations: 32, + max_transaction_ms: 5_000, + busy_timeout_ms: 5_000, + } + } +} + +#[cfg(feature = "sqlite")] +#[derive(Clone, Debug, Default)] +pub struct SqlitePolicy { + pub database_root: Option, + pub allow_unsafe_sql: bool, + pub limits: SqliteLimits, +} pub use crate::bytecode::{ CallableTarget, CallableValue, HostImport, OpCode, Program, Value, ValueType, }; @@ -228,25 +281,6 @@ pub struct InterpreterMetrics { pub local_type_hint_hit_count: u64, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[repr(u8)] -enum InterruptMode { - None = 0, - Fuel = 1, - Epoch = 2, -} - -impl InterruptMode { - fn label(self) -> &'static str { - match self { - Self::None => "none", - Self::Fuel => "fuel", - Self::Epoch => "epoch", - } - } -} -type RuntimePrintSink = dyn FnMut(String) + Send; - type PackedOperandTypes = u8; const NO_OPERAND_TYPE_HINT: PackedOperandTypes = 0; @@ -283,131 +317,12 @@ pub struct VmExecutionFrameSnapshot { pub prototype_id: Option, } -#[allow(dead_code)] -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum FrameContinuation { - Halt, - ResumeBytecode { return_ip: usize }, - ReturnToHost, -} - -#[allow(dead_code)] -#[derive(Clone, Debug)] -pub(crate) struct ExecutionFrame { - pub(crate) continuation: FrameContinuation, - pub(crate) operand_stack_base: usize, - pub(crate) local_base: usize, - pub(crate) local_count: usize, - pub(crate) prototype_id: Option, -} - -impl ExecutionFrame { - fn root(local_count: usize) -> Self { - Self { - continuation: FrameContinuation::Halt, - operand_stack_base: 0, - local_base: 0, - local_count, - prototype_id: None, - } - } -} - -#[derive(Clone, Debug)] -struct QueuedCallable { - callable: Value, - args: Vec, - subscription: Option>, -} - pub struct Vm { program: Arc, - #[allow(dead_code)] - program_constants_ptr: usize, - #[allow(dead_code)] - program_constants_len: usize, - #[allow(dead_code)] - native_helper_fn: usize, - #[allow(dead_code)] - native_interrupt_helper_fn: usize, - program_cache_key: u64, - program_cache_key_ready: bool, - ip: usize, - stack: Vec, - locals: Vec, - capture_cells: HashMap, - shared_capture_slots: HashSet, - operand_type_hints: Option>, - decoded_instruction_data: Arc, - host_functions: Vec, - host_function_symbols: HashMap, - allow_default_host_fallback: bool, - builtin_overrides: HashMap, - resolved_calls: Vec, - resolved_calls_dirty: bool, - call_depth: usize, - max_script_call_depth: usize, - execution_frames: Vec, - active_local_base_cache: usize, - active_operand_stack_base_cache: usize, - host_return: Option, - queued_callables: VecDeque, - completed_callable_results: VecDeque, - owned_callables: Vec>, - callback_registry_flags: Vec>, - draining_queued_callables: bool, - shutdown: bool, - aot_program: Option, - aot_exec_count: u64, - aot_interpreter_boundary_hit: bool, - jit: jit::TraceJitEngine, - native_traces: Vec>, - native_trace_exec_count: u64, - jit_native_region_entry_count: u64, - jit_native_region_edge_count: u64, - jit_native_direct_link_count: u64, - jit_native_direct_links_enabled: bool, - jit_native_direct_cross_frame_enabled: bool, - jit_native_active_direct_trace_id: usize, - jit_native_direct_escape_streak: u16, - jit_native_direct_region_fallback: bool, - jit_native_compile_time_ns: u64, - jit_native_region_compile_time_ns: u64, - jit_trace_exit_count: u64, - jit_native_loop_back_count: u64, - jit_native_link_handoff_count: u64, - jit_native_link_dispatch_depth: u32, - jit_helper_fallback_count: u64, - jit_native_bridge_stats_enabled: bool, - jit_native_bridge_counts: HashMap<&'static str, u64>, - async_bridge: Option>, - runtime_print_sink: Option>, - waiting_host_op: Option, - next_host_op_id: HostOpId, - pub(crate) io_state: crate::builtins::runtime::IoState, - pub(crate) http_state: crate::builtins::runtime::HttpState, - regex_cache: crate::builtins::runtime::regex::RegexCache, - map_iterators: Vec>>, - epoch_handle: EpochHandle, - #[allow(dead_code)] - epoch_counter_ptr: usize, - interrupt_mode: InterruptMode, - fuel_remaining: u64, - fuel_check_interval: u32, - fuel_ops_until_check: u32, - epoch_deadline: u64, - epoch_deadline_delta: u64, - epoch_rearm_pending: bool, - last_yield_reason: Option, - drop_contract_events_enabled: bool, - drop_contract_events: u64, - operand_hint_hit_count: u64, - operand_hint_miss_count: u64, - typed_builtin_fast_path_count: u64, - projection_fast_path_count: u64, - generic_builtin_call_count: u64, - scalar_superinstruction_count: u64, - local_type_hint_hit_count: u64, + pub(crate) engine: Engine, + pub(crate) instance: Instance, + pub(crate) run_ctx: RunContext, + pub(crate) host: HostRuntime, } pub(crate) enum ExecOutcome { @@ -662,128 +577,21 @@ impl Vm { } pub fn new_shared_with_jit_config(program: Arc, jit_config: jit::JitConfig) -> Self { - let program_constants_ptr = program.constants.as_ptr(); - let program_constants_len = program.constants.len(); - let local_count = program.local_count; - let operand_type_hints = program.shared_operand_type_hints(); - let decoded_instruction_data = program.shared_decoded_instruction_data(); - let epoch_handle = EpochHandle::default(); - let epoch_counter_ptr = epoch_handle.as_ptr() as usize; - let mut vm = Self { + let engine = Engine::new(jit_config, &program); + let mut instance = Instance::new(&program); + instance.initialize_root_callable_bindings(&program); + Self { program, - program_constants_ptr: program_constants_ptr as usize, - program_constants_len, - native_helper_fn: native::helper_entry_address(), - native_interrupt_helper_fn: native::interrupt_helper_entry_address(), - program_cache_key: 0, - program_cache_key_ready: false, - ip: 0, - stack: Vec::new(), - locals: vec![Value::Null; local_count], - capture_cells: HashMap::new(), - shared_capture_slots: HashSet::new(), - operand_type_hints, - decoded_instruction_data, - host_functions: Vec::new(), - host_function_symbols: HashMap::new(), - allow_default_host_fallback: true, - builtin_overrides: HashMap::new(), - resolved_calls: Vec::new(), - resolved_calls_dirty: true, - call_depth: 0, - max_script_call_depth: DEFAULT_MAX_SCRIPT_CALL_DEPTH, - execution_frames: vec![ExecutionFrame::root(local_count)], - active_local_base_cache: 0, - active_operand_stack_base_cache: 0, - host_return: None, - queued_callables: VecDeque::new(), - completed_callable_results: VecDeque::new(), - owned_callables: Vec::new(), - callback_registry_flags: Vec::new(), - draining_queued_callables: false, - shutdown: false, - aot_program: None, - aot_exec_count: 0, - aot_interpreter_boundary_hit: false, - jit: jit::TraceJitEngine::new(jit_config), - native_traces: Vec::new(), - native_trace_exec_count: 0, - jit_native_region_entry_count: 0, - jit_native_region_edge_count: 0, - jit_native_direct_link_count: 0, - jit_native_direct_links_enabled: true, - jit_native_direct_cross_frame_enabled: false, - jit_native_active_direct_trace_id: usize::MAX, - jit_native_direct_escape_streak: 0, - jit_native_direct_region_fallback: false, - jit_native_compile_time_ns: 0, - jit_native_region_compile_time_ns: 0, - jit_trace_exit_count: 0, - jit_native_loop_back_count: 0, - jit_native_link_handoff_count: 0, - jit_native_link_dispatch_depth: 0, - jit_helper_fallback_count: 0, - jit_native_bridge_stats_enabled: false, - jit_native_bridge_counts: HashMap::new(), - async_bridge: None, - runtime_print_sink: None, - waiting_host_op: None, - next_host_op_id: 1, - io_state: crate::builtins::runtime::IoState::default(), - http_state: crate::builtins::runtime::HttpState::default(), - regex_cache: crate::builtins::runtime::regex::RegexCache::default(), - map_iterators: Vec::new(), - epoch_handle, - epoch_counter_ptr, - interrupt_mode: InterruptMode::None, - fuel_remaining: 0, - fuel_check_interval: 1, - fuel_ops_until_check: 1, - epoch_deadline: 0, - epoch_deadline_delta: 0, - epoch_rearm_pending: false, - last_yield_reason: None, - drop_contract_events_enabled: false, - drop_contract_events: 0, - operand_hint_hit_count: 0, - operand_hint_miss_count: 0, - typed_builtin_fast_path_count: 0, - projection_fast_path_count: 0, - generic_builtin_call_count: 0, - scalar_superinstruction_count: 0, - local_type_hint_hit_count: 0, - }; - vm.initialize_root_callable_bindings(); - vm - } - - fn initialize_root_callable_bindings(&mut self) { - let bindings = self.program.root_callable_bindings.clone(); - for binding in bindings { - let Some(kind) = self - .program - .callable_prototypes - .get(binding.prototype_id as usize) - .map(|prototype| prototype.kind) - else { - continue; - }; - if binding.local_slot as usize >= self.locals.len() { - continue; - } - let callable = Arc::new(CallableValue { - prototype_id: binding.prototype_id, - kind, - env: None, - }); - self.owned_callables.push(Arc::downgrade(&callable)); - self.locals[binding.local_slot as usize] = Value::Callable(callable); + engine, + instance, + run_ctx: RunContext::default(), + host: HostRuntime::default(), } } /// Returns the maximum number of simultaneously active script call frames. pub fn max_script_call_depth(&self) -> usize { - self.max_script_call_depth + self.instance.max_script_call_depth } /// Sets the maximum number of simultaneously active script call frames. @@ -794,38 +602,34 @@ impl Vm { if limit == 0 { return Err(VmError::InvalidCallStackLimit(limit)); } - self.max_script_call_depth = limit; + self.instance.max_script_call_depth = limit; Ok(()) } fn ensure_program_cache_key(&mut self) -> u64 { - if !self.program_cache_key_ready { - self.program_cache_key = compute_program_cache_key(&self.program); - self.program_cache_key_ready = true; - } - self.program_cache_key + self.engine.ensure_program_cache_key(&self.program) } #[inline(always)] fn fuel_metering_enabled(&self) -> bool { - self.interrupt_mode == InterruptMode::Fuel + self.run_ctx.interrupt_mode == InterruptMode::Fuel } #[inline(always)] fn epoch_interruption_enabled(&self) -> bool { - self.interrupt_mode == InterruptMode::Epoch + self.run_ctx.interrupt_mode == InterruptMode::Epoch } #[inline(always)] fn interruption_enabled(&self) -> bool { - self.interrupt_mode != InterruptMode::None + self.run_ctx.interrupt_mode != InterruptMode::None } /// Returns the maximum number of compiled regular expressions retained by this VM. /// /// New VMs default to 512 entries. A capacity of zero disables caching. pub fn regex_cache_capacity(&self) -> usize { - self.regex_cache.capacity() + self.engine.regex_cache.capacity() } /// Changes this VM's compiled regular-expression cache capacity. @@ -833,67 +637,68 @@ impl Vm { /// Shrinking evicts least-recently-used entries immediately. Setting zero clears /// all entries and disables caching until a positive capacity is configured. pub fn set_regex_cache_capacity(&mut self, capacity: usize) { - self.regex_cache.set_capacity(capacity); + self.engine.regex_cache.set_capacity(capacity); } pub fn regex_cache_entry_count(&self) -> usize { - self.regex_cache.len() + self.engine.regex_cache.len() } pub fn regex_cache_compile_count(&self) -> u64 { - self.regex_cache.compile_count() + self.engine.regex_cache.compile_count() } pub fn regex_cache_hit_count(&self) -> u64 { - self.regex_cache.hit_count() + self.engine.regex_cache.hit_count() } pub(crate) fn cached_regex( &mut self, pattern: &str, ) -> Result, regex::Error> { - self.regex_cache.get_or_compile(pattern) + self.engine.regex_cache.get_or_compile(pattern) } pub fn set_jit_native_bridge_stats_enabled(&mut self, enabled: bool) { - self.jit_native_bridge_stats_enabled = enabled; + self.engine.jit_native_bridge_stats_enabled = enabled; if !enabled { - self.jit_native_bridge_counts.clear(); + self.engine.jit_native_bridge_counts.clear(); } } pub fn jit_native_bridge_stats_enabled(&self) -> bool { - self.jit_native_bridge_stats_enabled + self.engine.jit_native_bridge_stats_enabled } pub fn clear_jit_native_bridge_stats(&mut self) { - self.jit_native_bridge_counts.clear(); + self.engine.jit_native_bridge_counts.clear(); } pub fn interpreter_metrics_snapshot(&self) -> InterpreterMetrics { InterpreterMetrics { - operand_hint_hit_count: self.operand_hint_hit_count, - operand_hint_miss_count: self.operand_hint_miss_count, - typed_builtin_fast_path_count: self.typed_builtin_fast_path_count, - projection_fast_path_count: self.projection_fast_path_count, - generic_builtin_call_count: self.generic_builtin_call_count, - scalar_superinstruction_count: self.scalar_superinstruction_count, - local_type_hint_hit_count: self.local_type_hint_hit_count, + operand_hint_hit_count: self.instance.operand_hint_hit_count, + operand_hint_miss_count: self.instance.operand_hint_miss_count, + typed_builtin_fast_path_count: self.instance.typed_builtin_fast_path_count, + projection_fast_path_count: self.instance.projection_fast_path_count, + generic_builtin_call_count: self.instance.generic_builtin_call_count, + scalar_superinstruction_count: self.instance.scalar_superinstruction_count, + local_type_hint_hit_count: self.instance.local_type_hint_hit_count, } } pub fn clear_interpreter_metrics(&mut self) { - self.operand_hint_hit_count = 0; - self.operand_hint_miss_count = 0; - self.typed_builtin_fast_path_count = 0; - self.projection_fast_path_count = 0; - self.generic_builtin_call_count = 0; - self.scalar_superinstruction_count = 0; - self.local_type_hint_hit_count = 0; + self.instance.operand_hint_hit_count = 0; + self.instance.operand_hint_miss_count = 0; + self.instance.typed_builtin_fast_path_count = 0; + self.instance.projection_fast_path_count = 0; + self.instance.generic_builtin_call_count = 0; + self.instance.scalar_superinstruction_count = 0; + self.instance.local_type_hint_hit_count = 0; } pub fn jit_native_bridge_stats_snapshot(&self) -> Vec<(&'static str, u64)> { let mut entries: Vec<(&'static str, u64)> = self + .engine .jit_native_bridge_counts .iter() .map(|(name, count)| (*name, *count)) @@ -904,10 +709,11 @@ impl Vm { #[allow(dead_code)] pub(in crate::vm) fn record_native_bridge_hit(&mut self, bridge_name: &'static str) { - if !self.jit_native_bridge_stats_enabled { + if !self.engine.jit_native_bridge_stats_enabled { return; } let entry = self + .engine .jit_native_bridge_counts .entry(bridge_name) .or_insert(0); @@ -920,44 +726,13 @@ impl Vm { /// Locals are reset to `Null`, stack is cleared, and instruction pointer is /// rewound to the program entry. pub fn reset_for_reuse(&mut self) { - self.invalidate_callback_registries(); - self.cancel_waiting_host_op(); - self.ip = 0; - self.drop_contract_events = 0; - self.last_yield_reason = None; - self.epoch_rearm_pending = false; - self.clear_fuel(); - self.clear_epoch_deadline(); - self.clear_stack_with_drop_contract(); - self.capture_cells.clear(); - self.shared_capture_slots.clear(); - self.clear_locals_with_drop_contract(); - self.owned_callables.clear(); - self.locals.resize(self.program.local_count, Value::Null); - self.initialize_root_callable_bindings(); - crate::builtins::runtime::close_all_handles(self); - self.call_depth = 0; - self.execution_frames.clear(); - self.execution_frames - .push(ExecutionFrame::root(self.program.local_count)); - self.active_local_base_cache = 0; - self.active_operand_stack_base_cache = 0; - 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.aot_interpreter_boundary_hit = self - .aot_program - .as_ref() - .is_some_and(|program| program.interpreter_boundary_only); - self.waiting_host_op = None; - self.io_state = crate::builtins::runtime::IoState::default(); - self.map_iterators.clear(); - self.jit.reset_runtime_backoff(); - self.jit.clear_call_site_profiles(); - self.clear_interpreter_metrics(); + self.cancel_waiting_host_op_with_reason( + crate::builtins::runtime::cancellation::CancellationReason::VmReset, + ); + self.host.reset_for_reuse(); + self.run_ctx.reset_for_reuse(); + self.instance.reset(&self.program); + self.engine.reset_runtime_state(&self.program); } fn validate_map_iterator_slot(&self, slot: usize) -> VmResult<()> { @@ -976,11 +751,11 @@ impl Vm { map: crate::bytecode::SharedMap, ) -> VmResult<()> { self.validate_map_iterator_slot(slot)?; - let depth = self.call_depth; - if self.map_iterators.len() <= depth { - self.map_iterators.resize_with(depth + 1, Vec::new); + let depth = self.instance.call_depth; + if self.instance.map_iterators.len() <= depth { + self.instance.map_iterators.resize_with(depth + 1, Vec::new); } - let frame = &mut self.map_iterators[depth]; + let frame = &mut self.instance.map_iterators[depth]; if frame.len() <= slot { frame.resize_with(slot + 1, || None); } @@ -990,9 +765,13 @@ impl Vm { pub(crate) fn advance_map_iterator(&mut self, slot: usize) -> VmResult { self.validate_map_iterator_slot(slot)?; - let frame = self.map_iterators.get_mut(self.call_depth).ok_or_else(|| { - VmError::HostError("map iterator frame is not initialized".to_string()) - })?; + let frame = self + .instance + .map_iterators + .get_mut(self.instance.call_depth) + .ok_or_else(|| { + VmError::HostError("map iterator frame is not initialized".to_string()) + })?; let state = frame .get_mut(slot) .and_then(Option::as_mut) @@ -1006,8 +785,9 @@ impl Vm { pub(crate) fn take_map_iterator_key(&mut self, slot: usize) -> VmResult { self.validate_map_iterator_slot(slot)?; - self.map_iterators - .get_mut(self.call_depth) + self.instance + .map_iterators + .get_mut(self.instance.call_depth) .and_then(|frame| frame.get_mut(slot)) .and_then(Option::as_mut) .and_then(map_iter::MapIteratorState::take_key) @@ -1016,8 +796,9 @@ impl Vm { pub(crate) fn take_map_iterator_value(&mut self, slot: usize) -> VmResult { self.validate_map_iterator_slot(slot)?; - self.map_iterators - .get_mut(self.call_depth) + self.instance + .map_iterators + .get_mut(self.instance.call_depth) .and_then(|frame| frame.get_mut(slot)) .and_then(Option::as_mut) .and_then(map_iter::MapIteratorState::take_value) @@ -1027,8 +808,9 @@ impl Vm { pub(crate) fn close_map_iterator(&mut self, slot: usize) -> VmResult<()> { self.validate_map_iterator_slot(slot)?; if let Some(state) = self + .instance .map_iterators - .get_mut(self.call_depth) + .get_mut(self.instance.call_depth) .and_then(|frame| frame.get_mut(slot)) { *state = None; @@ -1037,7 +819,7 @@ impl Vm { } fn close_all_map_iterators(&mut self) { - for frame in &mut self.map_iterators { + for frame in &mut self.instance.map_iterators { for state in frame { state.take(); } @@ -1046,19 +828,21 @@ impl Vm { #[inline(always)] pub(super) fn active_operand_stack_base(&self) -> usize { - self.active_operand_stack_base_cache + self.instance.active_operand_stack_base_cache } #[inline(always)] pub(super) fn active_operand_stack_len(&self) -> usize { - self.stack + self.instance + .stack .len() .saturating_sub(self.active_operand_stack_base()) } #[inline(always)] pub(super) fn active_frame_key(&self) -> u64 { - self.execution_frames + self.instance + .execution_frames .last() .and_then(|frame| frame.prototype_id) .map(u64::from) @@ -1067,11 +851,11 @@ impl Vm { #[inline(always)] pub(super) fn active_local_base(&self) -> usize { - self.active_local_base_cache + self.instance.active_local_base_cache } pub(super) fn active_local_types(&self) -> Vec { - self.locals[self.active_local_base()..] + self.instance.locals[self.active_local_base()..] .iter() .map(|value| match value { Value::Null => ValueType::Null, @@ -1089,9 +873,10 @@ impl Vm { pub(super) fn active_local_callable_prototypes(&self) -> Option>> { let base = self.active_local_base(); - let mut prototypes = Vec::with_capacity(self.locals.len().saturating_sub(base)); - for (offset, value) in self.locals[base..].iter().enumerate() { - let prototype_id = if let Some(cell) = self.capture_cells.get(&(base + offset)) { + let mut prototypes = Vec::with_capacity(self.instance.locals.len().saturating_sub(base)); + for (offset, value) in self.instance.locals[base..].iter().enumerate() { + let prototype_id = if let Some(cell) = self.instance.capture_cells.get(&(base + offset)) + { let value = cell.lock().ok()?; inline_compatible_callable_prototype(&value) } else { @@ -1103,21 +888,23 @@ impl Vm { } pub(super) fn active_frame_has_shared_capture_cells(&self) -> bool { - if self.shared_capture_slots.is_empty() { + if self.instance.shared_capture_slots.is_empty() { return false; } - let Some(frame) = self.execution_frames.last() else { + let Some(frame) = self.instance.execution_frames.last() else { return false; }; let base = frame.local_base; let end = base.saturating_add(frame.local_count); - self.shared_capture_slots + self.instance + .shared_capture_slots .iter() .any(|absolute| base <= *absolute && *absolute < end) } fn script_frame_depth(&self) -> usize { - self.execution_frames + self.instance + .execution_frames .iter() .filter(|frame| frame.prototype_id.is_some()) .count() @@ -1129,7 +916,8 @@ impl Vm { .active_local_base() .checked_add(index as usize) .ok_or(VmError::InvalidLocal(index))?; - self.locals + self.instance + .locals .get(absolute) .map(|_| absolute) .ok_or(VmError::InvalidLocal(index)) @@ -1138,8 +926,8 @@ impl Vm { #[inline(always)] fn load_local_value(&self, index: u8) -> VmResult { let absolute = self.absolute_local_index(index)?; - if self.capture_cells.is_empty() { - return Ok(self.locals[absolute].clone()); + if self.instance.capture_cells.is_empty() { + return Ok(self.instance.locals[absolute].clone()); } self.load_local_value_with_captures(absolute, index) } @@ -1147,13 +935,14 @@ impl Vm { #[cold] #[inline(never)] fn load_local_value_with_captures(&self, absolute: usize, index: u8) -> VmResult { - if let Some(cell) = self.capture_cells.get(&absolute) { + if let Some(cell) = self.instance.capture_cells.get(&absolute) { return cell .lock() .map(|value| value.clone()) .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned")); } - self.locals + self.instance + .locals .get(absolute) .cloned() .ok_or(VmError::InvalidLocal(index)) @@ -1162,8 +951,8 @@ impl Vm { #[inline(always)] pub(super) fn local_numeric_value(&self, index: u8) -> Option { let absolute = self.absolute_local_index(index).ok()?; - if self.capture_cells.is_empty() { - return match self.locals.get(absolute)? { + if self.instance.capture_cells.is_empty() { + return match self.instance.locals.get(absolute)? { Value::Int(value) => Some(NumericValue::Int(*value)), Value::Float(value) => Some(NumericValue::Float(*value)), _ => None, @@ -1176,10 +965,14 @@ impl Vm { #[inline(never)] fn local_numeric_value_with_captures(&self, absolute: usize) -> Option { let captured = self + .instance .capture_cells .get(&absolute) .and_then(|cell| cell.lock().ok().map(|value| value.clone())); - match captured.as_ref().or_else(|| self.locals.get(absolute))? { + match captured + .as_ref() + .or_else(|| self.instance.locals.get(absolute))? + { Value::Int(value) => Some(NumericValue::Int(*value)), Value::Float(value) => Some(NumericValue::Float(*value)), _ => None, @@ -1187,33 +980,33 @@ impl Vm { } pub fn drop_contract_event_count(&self) -> u64 { - self.drop_contract_events + self.instance.drop_contract_events } pub fn set_drop_contract_events_enabled(&mut self, enabled: bool) { - if self.drop_contract_events_enabled != enabled { + if self.instance.drop_contract_events_enabled != enabled { self.disconnect_native_regions(); - self.native_traces.clear(); + self.engine.invalidate_codegen_caches(); } - self.drop_contract_events_enabled = enabled; + self.instance.drop_contract_events_enabled = enabled; if !enabled { - self.drop_contract_events = 0; + self.instance.drop_contract_events = 0; } } pub fn drop_contract_events_enabled(&self) -> bool { - self.drop_contract_events_enabled + self.instance.drop_contract_events_enabled } fn interruption_mode_conflict(&self, requested: InterruptMode) -> VmError { VmError::InterruptionModeConflict { - active: self.interrupt_mode.label(), + active: self.run_ctx.interrupt_mode.label(), requested: requested.label(), } } fn reset_interrupt_countdown(&mut self) { - self.fuel_ops_until_check = self.fuel_check_interval.max(1); + self.run_ctx.fuel_ops_until_check = self.run_ctx.fuel_check_interval.max(1); } pub fn run(&mut self) -> VmResult { @@ -1230,18 +1023,17 @@ impl Vm { impl Drop for Vm { fn drop(&mut self) { - self.cancel_waiting_host_op(); - self.clear_stack_with_drop_contract(); - self.capture_cells.clear(); - self.shared_capture_slots.clear(); - self.clear_locals_with_drop_contract(); - crate::builtins::runtime::close_all_handles(self); + self.cancel_waiting_host_op_with_reason( + crate::builtins::runtime::cancellation::CancellationReason::VmReset, + ); + self.host.reset_for_reuse(); + self.instance.drop_cleanup(); } } impl Vm { pub(super) fn pop_value(&mut self) -> VmResult { - self.stack.pop().ok_or(VmError::StackUnderflow) + self.instance.stack.pop().ok_or(VmError::StackUnderflow) } pub(crate) fn bind_callable_value( @@ -1280,18 +1072,19 @@ impl Vm { let absolute = active_base .checked_add(usize::from(*source)) .ok_or(VmError::InvalidFrameState("capture source slot overflow"))?; - if absolute >= self.locals.len() { + if absolute >= self.instance.locals.len() { return Err(VmError::InvalidFrameState( "capture source exceeds active frame locals", )); } let cell = self + .instance .capture_cells .entry(absolute) .or_insert_with(|| Arc::new(Mutex::new(value))) .clone(); - self.shared_capture_slots.insert(absolute); - self.locals[absolute] = cell + self.instance.shared_capture_slots.insert(absolute); + self.instance.locals[absolute] = cell .lock() .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned"))? .clone(); @@ -1313,7 +1106,9 @@ impl Vm { kind: prototype.kind, env, }); - self.owned_callables.push(Arc::downgrade(&callable)); + self.instance + .owned_callables + .push(Arc::downgrade(&callable)); Ok(Value::Callable(callable)) } @@ -1323,11 +1118,11 @@ impl Vm { call_site_ip: Option, ) -> VmResult { let operand_count = argc as usize + 1; - if self.stack.len() < operand_count { + if self.instance.stack.len() < operand_count { return Err(VmError::StackUnderflow); } - let operand_stack_base = self.stack.len() - operand_count; - let mut operands = self.stack.split_off(operand_stack_base); + let operand_stack_base = self.instance.stack.len() - operand_count; + let mut operands = self.instance.stack.split_off(operand_stack_base); let callee = operands.remove(0); let Value::Callable(callable) = callee else { return Err(VmError::InvalidCallable); @@ -1358,15 +1153,15 @@ impl Vm { match prototype.target { CallableTarget::ScriptFunction(function_id) => { if let Some(call_ip) = call_site_ip { - self.jit.observe_script_call_target( + self.engine.jit.observe_script_call_target( self.active_frame_key(), call_ip, callable.prototype_id, ); } - if self.call_depth >= self.max_script_call_depth { + if self.instance.call_depth >= self.instance.max_script_call_depth { return Err(VmError::CallStackOverflow { - limit: self.max_script_call_depth, + limit: self.instance.max_script_call_depth, }); } let function = self @@ -1383,10 +1178,11 @@ impl Vm { }); } let inherited_callables = self + .instance .execution_frames .last() .map(|frame| { - self.locals[frame.local_base..frame.local_base + frame.local_count] + self.instance.locals[frame.local_base..frame.local_base + frame.local_count] .iter() .enumerate() .filter(|(_, value)| matches!(value, Value::Callable(_))) @@ -1394,9 +1190,10 @@ impl Vm { .collect::>() }) .unwrap_or_default(); - let local_base = self.locals.len(); + let local_base = self.instance.locals.len(); let local_count = prototype.frame_local_count; - self.locals + self.instance + .locals .resize(local_base.saturating_add(local_count), Value::Null); for binding in &self.program.root_callable_bindings { let relative = binding.local_slot as usize; @@ -1416,12 +1213,14 @@ impl Vm { kind, env: None, }); - self.owned_callables.push(Arc::downgrade(&callable)); - self.locals[local_base + relative] = Value::Callable(callable); + self.instance + .owned_callables + .push(Arc::downgrade(&callable)); + self.instance.locals[local_base + relative] = Value::Callable(callable); } for (slot, value) in inherited_callables { if slot < local_count { - self.locals[local_base + slot] = value; + self.instance.locals[local_base + slot] = value; } } for (slot, argument) in prototype.parameter_slots.iter().zip(operands) { @@ -1431,7 +1230,7 @@ impl Vm { "parameter slot is outside the script frame", )); } - self.locals[local_base + relative] = argument; + self.instance.locals[local_base + relative] = argument; } if let Some(environment) = &callable.env { let cells = environment @@ -1456,20 +1255,20 @@ impl Vm { )); } let absolute = local_base + relative; - self.locals[absolute] = cell + self.instance.locals[absolute] = cell .lock() .map_err(|_| { VmError::InvalidFrameState("capture cell lock is poisoned") })? .clone(); if prototype.self_slot != Some(*slot) { - self.capture_cells.insert(absolute, cell.clone()); + self.instance.capture_cells.insert(absolute, cell.clone()); if matches!( mode, crate::CaptureBindingMode::Borrow | crate::CaptureBindingMode::BorrowMut ) { - self.shared_capture_slots.insert(absolute); + self.instance.shared_capture_slots.insert(absolute); } } } @@ -1481,31 +1280,32 @@ impl Vm { "self slot is outside the script frame", )); } - self.locals[local_base + relative] = Value::Callable(callable.clone()); + self.instance.locals[local_base + relative] = Value::Callable(callable.clone()); } - let return_ip = self.ip; - self.execution_frames.push(ExecutionFrame { + let return_ip = self.instance.ip; + self.instance.execution_frames.push(ExecutionFrame { continuation: FrameContinuation::ResumeBytecode { return_ip }, operand_stack_base, local_base, local_count, prototype_id: Some(callable.prototype_id), }); - self.active_local_base_cache = local_base; - self.active_operand_stack_base_cache = operand_stack_base; - self.call_depth = self.script_frame_depth(); - self.ip = function.entry_ip as usize; + self.instance.active_local_base_cache = local_base; + self.instance.active_operand_stack_base_cache = operand_stack_base; + self.instance.call_depth = self.script_frame_depth(); + self.instance.ip = function.entry_ip as usize; self.charge_interrupt_tick()?; Ok(ExecOutcome::Continue) } CallableTarget::HostImport(import_index) => { - self.stack.extend(operands); - let call_ip = self.ip.saturating_sub(2); + self.instance.stack.extend(operands); + let call_ip = self.instance.ip.saturating_sub(2); match self.execute_host_call(import_index, argc, call_ip)? { HostCallExecOutcome::Returned => Ok(ExecOutcome::Continue), HostCallExecOutcome::Halted => Ok(ExecOutcome::Halted), HostCallExecOutcome::Yielded => { - self.stack + self.instance + .stack .insert(operand_stack_base, Value::Callable(callable)); Ok(ExecOutcome::Yielded) } @@ -1517,45 +1317,57 @@ impl Vm { fn complete_active_frame(&mut self) -> VmResult { let frame = self + .instance .execution_frames .pop() .ok_or(VmError::InvalidFrameState("missing active frame"))?; - self.active_local_base_cache = self + self.instance.active_local_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.local_base) .unwrap_or(0); - self.active_operand_stack_base_cache = self + self.instance.active_operand_stack_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.operand_stack_base) .unwrap_or(0); - if self.stack.len() < frame.operand_stack_base { + if self.instance.stack.len() < frame.operand_stack_base { return Err(VmError::InvalidFrameState( "operand stack is below the active frame base", )); } if matches!(frame.continuation, FrameContinuation::Halt) { - self.call_depth = self.script_frame_depth(); + self.instance.call_depth = self.script_frame_depth(); return Ok(ExecOutcome::Halted); } - let result = if self.stack.len() > frame.operand_stack_base { - self.stack.pop().expect("stack length checked above") + let result = if self.instance.stack.len() > frame.operand_stack_base { + self.instance + .stack + .pop() + .expect("stack length checked above") } else { Value::Null }; - while self.stack.len() > frame.operand_stack_base { - let value = self.stack.pop().expect("stack length checked above"); + while self.instance.stack.len() > frame.operand_stack_base { + let value = self + .instance + .stack + .pop() + .expect("stack length checked above"); self.drop_value_with_contract(value); } - self.call_depth = self.script_frame_depth(); + self.instance.call_depth = self.script_frame_depth(); if frame.prototype_id.is_some() { let frame_end = frame.local_base.saturating_add(frame.local_count); - self.capture_cells + self.instance + .capture_cells .retain(|absolute, _| *absolute < frame.local_base || *absolute >= frame_end); - self.shared_capture_slots + self.instance + .shared_capture_slots .retain(|absolute| *absolute < frame.local_base || *absolute >= frame_end); } @@ -1564,12 +1376,16 @@ impl Vm { .local_base .checked_add(frame.local_count) .ok_or(VmError::InvalidFrameState("local frame range overflow"))?; - if frame_end != self.locals.len() { + if frame_end != self.instance.locals.len() { return Err(VmError::InvalidFrameState( "active local frame does not end at the local stack tail", )); } - let drained = self.locals.drain(frame.local_base..).collect::>(); + let drained = self + .instance + .locals + .drain(frame.local_base..) + .collect::>(); for value in drained { self.drop_value_with_contract(value); } @@ -1589,16 +1405,16 @@ impl Vm { match frame.continuation { FrameContinuation::Halt => { - self.stack.push(result); + self.instance.stack.push(result); Ok(ExecOutcome::Halted) } FrameContinuation::ResumeBytecode { return_ip } => { - self.ip = return_ip; - self.stack.push(result); + self.instance.ip = return_ip; + self.instance.stack.push(result); Ok(ExecOutcome::Continue) } FrameContinuation::ReturnToHost => { - self.host_return = Some(result); + self.instance.host_return = Some(result); Ok(ExecOutcome::Halted) } } @@ -1606,25 +1422,25 @@ impl Vm { pub(super) fn can_fuse_call_ret_pattern(&self) -> bool { let code = &self.program.code; - self.ip < code.len() && code[self.ip] == OpCode::Ret as u8 + self.instance.ip < code.len() && code[self.instance.ip] == OpCode::Ret as u8 } pub(super) fn clear_stack_with_drop_contract(&mut self) { - let drained = self.stack.drain(..).collect::>(); + let drained = self.instance.stack.drain(..).collect::>(); for value in drained { self.drop_value_with_contract(value); } } pub(super) fn clear_locals_with_drop_contract(&mut self) { - for slot in 0..self.locals.len() { - let previous = std::mem::replace(&mut self.locals[slot], Value::Null); + for slot in 0..self.instance.locals.len() { + let previous = std::mem::replace(&mut self.instance.locals[slot], Value::Null); self.drop_value_with_contract(previous); } } pub(super) fn drop_value_with_contract(&mut self, value: Value) { - if self.drop_contract_events_enabled { + if self.instance.drop_contract_events_enabled { self.count_value_drop_contract(&value); } } @@ -1633,13 +1449,15 @@ impl Vm { match value { Value::Null => {} Value::Array(values) => { - self.drop_contract_events = self.drop_contract_events.saturating_add(1); + self.instance.drop_contract_events = + self.instance.drop_contract_events.saturating_add(1); for item in values.iter() { self.count_value_drop_contract(item); } } Value::Map(entries) => { - self.drop_contract_events = self.drop_contract_events.saturating_add(1); + self.instance.drop_contract_events = + self.instance.drop_contract_events.saturating_add(1); for (key, value) in entries.iter() { self.count_value_drop_contract(key); self.count_value_drop_contract(value); @@ -1651,14 +1469,15 @@ impl Vm { | Value::String(_) | Value::Bytes(_) | Value::Callable(_) => { - self.drop_contract_events = self.drop_contract_events.saturating_add(1); + self.instance.drop_contract_events = + self.instance.drop_contract_events.saturating_add(1); } } } #[inline(always)] pub(in crate::vm) fn charge_interrupt_tick(&mut self) -> VmResult<()> { - match self.interrupt_mode { + match self.run_ctx.interrupt_mode { InterruptMode::None => Ok(()), InterruptMode::Fuel => self.charge_fuel_tick(), InterruptMode::Epoch => self.charge_epoch_tick(), @@ -1668,15 +1487,15 @@ impl Vm { #[inline(always)] #[allow(dead_code)] pub(in crate::vm) fn charge_aot_call_boundary_interrupt(&mut self) -> VmResult<()> { - match self.interrupt_mode { + match self.run_ctx.interrupt_mode { InterruptMode::None => Ok(()), InterruptMode::Fuel => self.charge_fuel(1), InterruptMode::Epoch => { let current = self.current_epoch(); - if current >= self.epoch_deadline { + if current >= self.run_ctx.epoch_deadline { return Err(VmError::EpochDeadlineReached { current, - deadline: self.epoch_deadline, + deadline: self.run_ctx.epoch_deadline, }); } Ok(()) @@ -1685,7 +1504,7 @@ impl Vm { } pub(super) fn peek_value(&self) -> VmResult<&Value> { - self.stack.last().ok_or(VmError::StackUnderflow) + self.instance.stack.last().ok_or(VmError::StackUnderflow) } pub(super) fn pop_int(&mut self) -> VmResult { @@ -1709,7 +1528,8 @@ impl Vm { #[inline(always)] pub(super) fn operand_type_hint(&self, ip: usize) -> PackedOperandTypes { - self.operand_type_hints + self.engine + .operand_type_hints .as_deref() .map_or(NO_OPERAND_TYPE_HINT, |hints| hints[ip]) } @@ -1731,57 +1551,68 @@ impl Vm { #[inline(always)] pub(super) fn record_local_type_hint_hit(&mut self) { - self.local_type_hint_hit_count = self.local_type_hint_hit_count.saturating_add(1); + self.instance.local_type_hint_hit_count = + self.instance.local_type_hint_hit_count.saturating_add(1); } #[inline(always)] pub(super) fn record_scalar_superinstruction(&mut self) { - self.scalar_superinstruction_count = self.scalar_superinstruction_count.saturating_add(1); + self.instance.scalar_superinstruction_count = self + .instance + .scalar_superinstruction_count + .saturating_add(1); } #[inline(always)] pub(super) fn record_typed_builtin_fast_path(&mut self) { - self.typed_builtin_fast_path_count = self.typed_builtin_fast_path_count.saturating_add(1); + self.instance.typed_builtin_fast_path_count = self + .instance + .typed_builtin_fast_path_count + .saturating_add(1); } #[inline(always)] pub(super) fn record_projection_fast_path(&mut self) { - self.projection_fast_path_count = self.projection_fast_path_count.saturating_add(1); + self.instance.projection_fast_path_count = + self.instance.projection_fast_path_count.saturating_add(1); } #[inline(always)] pub(super) fn record_generic_builtin_call(&mut self) { - self.generic_builtin_call_count = self.generic_builtin_call_count.saturating_add(1); + self.instance.generic_builtin_call_count = + self.instance.generic_builtin_call_count.saturating_add(1); } #[inline(always)] fn record_operand_hint_hit(&mut self) { - self.operand_hint_hit_count = self.operand_hint_hit_count.saturating_add(1); + self.instance.operand_hint_hit_count = + self.instance.operand_hint_hit_count.saturating_add(1); } #[inline(always)] fn record_operand_hint_miss(&mut self) { - self.operand_hint_miss_count = self.operand_hint_miss_count.saturating_add(1); + self.instance.operand_hint_miss_count = + self.instance.operand_hint_miss_count.saturating_add(1); } #[inline(always)] pub(super) fn unary_not_op(&mut self) -> VmResult<()> { let value = self.pop_bool()?; - self.stack.push(Value::Bool(!value)); + self.instance.stack.push(Value::Bool(!value)); Ok(()) } pub(super) fn int_add_op(&mut self) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(lhs.wrapping_add(rhs))); + self.instance.stack.push(Value::Int(lhs.wrapping_add(rhs))); Ok(()) } pub(super) fn float_add_op(&mut self) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Float(lhs + rhs)); + self.instance.stack.push(Value::Float(lhs + rhs)); Ok(()) } @@ -1797,7 +1628,7 @@ impl Vm { let mut out = String::with_capacity(lhs.len() + rhs.len()); out.push_str(lhs.as_str()); out.push_str(rhs.as_str()); - self.stack.push(Value::string(out)); + self.instance.stack.push(Value::string(out)); Ok(()) } @@ -1812,7 +1643,7 @@ impl Vm { }; let mut out = crate::bytecode::unwrap_or_clone_shared(lhs); out.extend(crate::bytecode::unwrap_or_clone_shared(rhs)); - self.stack.push(Value::bytes(out)); + self.instance.stack.push(Value::bytes(out)); Ok(()) } @@ -1822,7 +1653,7 @@ impl Vm { ) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(op(lhs, rhs)?)); + self.instance.stack.push(Value::Int(op(lhs, rhs)?)); Ok(()) } @@ -1832,40 +1663,40 @@ impl Vm { ) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Float(op(lhs, rhs)?)); + self.instance.stack.push(Value::Float(op(lhs, rhs)?)); Ok(()) } pub(super) fn int_neg_op(&mut self) -> VmResult<()> { let value = self.pop_int()?; - self.stack.push(Value::Int(value.wrapping_neg())); + self.instance.stack.push(Value::Int(value.wrapping_neg())); Ok(()) } pub(super) fn float_neg_op(&mut self) -> VmResult<()> { let value = self.pop_float_exact()?; - self.stack.push(Value::Float(-value)); + self.instance.stack.push(Value::Float(-value)); Ok(()) } pub(super) fn int_eq_op(&mut self) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } pub(super) fn float_eq_op(&mut self) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } pub(super) fn bool_eq_op(&mut self) -> VmResult<()> { let rhs = self.pop_bool()?; let lhs = self.pop_bool()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } @@ -1878,7 +1709,7 @@ impl Vm { Value::String(value) => value, _ => return Err(VmError::TypeMismatch("string")), }; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } @@ -1887,7 +1718,7 @@ impl Vm { let lhs = self.pop_value()?; match (lhs, rhs) { (Value::Null, Value::Null) => { - self.stack.push(Value::Bool(true)); + self.instance.stack.push(Value::Bool(true)); Ok(()) } _ => Err(VmError::TypeMismatch("null")), @@ -1897,14 +1728,14 @@ impl Vm { pub(super) fn int_compare_op(&mut self, op: impl FnOnce(i64, i64) -> bool) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Bool(op(lhs, rhs))); + self.instance.stack.push(Value::Bool(op(lhs, rhs))); Ok(()) } pub(super) fn float_compare_op(&mut self, op: impl FnOnce(f64, f64) -> bool) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Bool(op(lhs, rhs))); + self.instance.stack.push(Value::Bool(op(lhs, rhs))); Ok(()) } @@ -1913,26 +1744,32 @@ impl Vm { let lhs = self.pop_value()?; match (lhs, rhs) { (Value::Int(lhs), Value::Int(rhs)) => { - self.stack.push(Value::Int(lhs.wrapping_add(rhs))) + self.instance.stack.push(Value::Int(lhs.wrapping_add(rhs))) + } + (Value::Int(lhs), Value::Float(rhs)) => { + self.instance.stack.push(Value::Float(lhs as f64 + rhs)) + } + (Value::Float(lhs), Value::Int(rhs)) => { + self.instance.stack.push(Value::Float(lhs + rhs as f64)) + } + (Value::Float(lhs), Value::Float(rhs)) => { + self.instance.stack.push(Value::Float(lhs + rhs)) } - (Value::Int(lhs), Value::Float(rhs)) => self.stack.push(Value::Float(lhs as f64 + rhs)), - (Value::Float(lhs), Value::Int(rhs)) => self.stack.push(Value::Float(lhs + rhs as f64)), - (Value::Float(lhs), Value::Float(rhs)) => self.stack.push(Value::Float(lhs + rhs)), (Value::String(lhs), Value::String(rhs)) => { let mut out = String::with_capacity(lhs.len() + rhs.len()); out.push_str(lhs.as_str()); out.push_str(rhs.as_str()); - self.stack.push(Value::string(out)); + self.instance.stack.push(Value::string(out)); } (Value::Bytes(lhs), Value::Bytes(rhs)) => { let mut out = crate::bytecode::unwrap_or_clone_shared(lhs); out.extend(crate::bytecode::unwrap_or_clone_shared(rhs)); - self.stack.push(Value::bytes(out)); + self.instance.stack.push(Value::bytes(out)); } (Value::Array(lhs), Value::Array(rhs)) => { let mut out = crate::bytecode::unwrap_or_clone_shared(lhs); out.extend(crate::bytecode::unwrap_or_clone_shared(rhs)); - self.stack.push(Value::array(out)); + self.instance.stack.push(Value::array(out)); } _ => { return Err(VmError::TypeMismatch( @@ -1952,7 +1789,7 @@ impl Vm { let lhs = self.pop_numeric()?; match (lhs, rhs) { (NumericValue::Int(lhs), NumericValue::Int(rhs)) => { - self.stack.push(Value::Int(int_op(lhs, rhs)?)); + self.instance.stack.push(Value::Int(int_op(lhs, rhs)?)); } (lhs, rhs) => { let lhs = match lhs { @@ -1963,7 +1800,7 @@ impl Vm { NumericValue::Int(v) => v as f64, NumericValue::Float(v) => v, }; - self.stack.push(Value::Float(float_op(lhs, rhs)?)); + self.instance.stack.push(Value::Float(float_op(lhs, rhs)?)); } } Ok(()) @@ -1990,7 +1827,7 @@ impl Vm { float_op(lhs, rhs) } }; - self.stack.push(Value::Bool(result)); + self.instance.stack.push(Value::Bool(result)); Ok(()) } @@ -2019,8 +1856,9 @@ impl Vm { index: u8, value: Value, ) -> VmResult<()> { - if self.capture_cells.is_empty() { + if self.instance.capture_cells.is_empty() { let slot = self + .instance .locals .get_mut(absolute) .ok_or(VmError::InvalidLocal(index))?; @@ -2039,7 +1877,7 @@ impl Vm { index: u8, value: Value, ) -> VmResult<()> { - if let Some(cell) = self.capture_cells.get(&absolute).cloned() { + if let Some(cell) = self.instance.capture_cells.get(&absolute).cloned() { if Self::value_references_capture_cell(&value, &cell, &mut HashSet::new())? { return Err(VmError::InvalidFrameState( "callable capture ownership cycle is unsupported", @@ -2051,11 +1889,12 @@ impl Vm { .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned"))?; std::mem::replace(&mut *captured, value.clone()) }; - self.locals[absolute] = value; + self.instance.locals[absolute] = value; self.drop_value_with_contract(previous); return Ok(()); } let slot = self + .instance .locals .get_mut(absolute) .ok_or(VmError::InvalidLocal(index))?; @@ -2117,8 +1956,9 @@ impl Vm { pub(crate) fn detach_local_with_drop_contract(&mut self, index: u8) -> VmResult<()> { let absolute = self.absolute_local_index(index)?; - self.capture_cells.remove(&absolute); + self.instance.capture_cells.remove(&absolute); let slot = self + .instance .locals .get_mut(absolute) .ok_or(VmError::InvalidLocal(index))?; @@ -2128,11 +1968,11 @@ impl Vm { } pub(super) fn read_u8(&mut self) -> VmResult { - if self.ip >= self.program.code.len() { + if self.instance.ip >= self.program.code.len() { return Err(VmError::BytecodeBounds); } - let value = self.program.code[self.ip]; - self.ip += 1; + let value = self.program.code[self.instance.ip]; + self.instance.ip += 1; Ok(value) } @@ -2147,12 +1987,13 @@ impl Vm { } pub(super) fn read_bytes(&mut self, count: usize) -> VmResult<[u8; 4]> { - if self.ip + count > self.program.code.len() { + if self.instance.ip + count > self.program.code.len() { return Err(VmError::BytecodeBounds); } let mut buf = [0u8; 4]; - buf[..count].copy_from_slice(&self.program.code[self.ip..self.ip + count]); - self.ip += count; + buf[..count] + .copy_from_slice(&self.program.code[self.instance.ip..self.instance.ip + count]); + self.instance.ip += count; Ok(buf) } @@ -2162,6 +2003,7 @@ impl Vm { } if !self.program.function_regions.is_empty() { let active_prototype = self + .instance .execution_frames .last() .and_then(|frame| frame.prototype_id); @@ -2195,7 +2037,7 @@ impl Vm { return Err(VmError::InvalidBranchTarget { target }); } } - self.ip = target; + self.instance.ip = target; Ok(()) } } @@ -2252,10 +2094,10 @@ impl Vm { ) -> Option { match outcome { ExecOutcome::Continue => {} - ExecOutcome::Halted | ExecOutcome::Waiting(_) => self.last_yield_reason = None, + ExecOutcome::Halted | ExecOutcome::Waiting(_) => self.instance.last_yield_reason = None, ExecOutcome::Yielded => { - if self.last_yield_reason.is_none() { - self.last_yield_reason = Some(VmYieldReason::Host); + if self.instance.last_yield_reason.is_none() { + self.instance.last_yield_reason = Some(VmYieldReason::Host); } } } @@ -2278,7 +2120,7 @@ impl Vm { fn run_fast_interpreter(&mut self, allow_jit: bool) -> VmResult> { loop { - if self.ip >= self.program.code.len() { + if self.instance.ip >= self.program.code.len() { return Err(VmError::BytecodeBounds); } let opcode = self.read_u8()?; @@ -2286,17 +2128,17 @@ impl Vm { match outcome { ExecOutcome::Continue => {} ExecOutcome::Halted => { - self.last_yield_reason = None; + self.instance.last_yield_reason = None; return Ok(Some(VmStatus::Halted)); } ExecOutcome::Yielded => { - if self.last_yield_reason.is_none() { - self.last_yield_reason = Some(VmYieldReason::Host); + if self.instance.last_yield_reason.is_none() { + self.instance.last_yield_reason = Some(VmYieldReason::Host); } return Ok(Some(VmStatus::Yielded)); } ExecOutcome::Waiting(op_id) => { - self.last_yield_reason = None; + self.instance.last_yield_reason = None; return Ok(Some(VmStatus::Waiting(op_id))); } } @@ -2316,28 +2158,28 @@ impl Vm { ) -> VmResult { self.ensure_call_bindings()?; self.sync_jit_non_yielding_host_imports(); - if let Some(waiting) = self.waiting_host_op { - self.last_yield_reason = None; + if let Some(waiting) = self.instance.waiting_host_op { + self.instance.last_yield_reason = None; let status = VmStatus::Waiting(waiting.op_id); self.notify_debugger_status(&mut debugger, status); return Ok(status); } - self.last_yield_reason = None; - if self.epoch_rearm_pending { + self.instance.last_yield_reason = None; + if self.run_ctx.epoch_rearm_pending { self.rearm_epoch_after_yield_if_needed(); } if debugger.is_none() && !self.interruption_enabled() && (!allow_jit || (!self.jit_config().enabled - && (!self.has_aot_program() || self.aot_interpreter_boundary_hit))) + && (!self.has_aot_program() || self.engine.aot_interpreter_boundary_hit))) && let Some(status) = self.run_fast_interpreter(allow_jit)? { return Ok(status); } loop { - if self.epoch_rearm_pending { + if self.run_ctx.epoch_rearm_pending { self.rearm_epoch_after_yield_if_needed(); } if let Some(active_debugger) = debugger.as_deref_mut() { @@ -2346,7 +2188,7 @@ impl Vm { if allow_jit && self.has_aot_program() - && !self.aot_interpreter_boundary_hit + && !self.engine.aot_interpreter_boundary_hit && !self.drop_contract_events_enabled() { let outcome = match self.execute_aot_entry() { @@ -2373,7 +2215,7 @@ impl Vm { continue; } - if self.aot_interpreter_boundary_hit + if self.engine.aot_interpreter_boundary_hit && debugger.is_none() && !self.interruption_enabled() && !self.jit_config().enabled @@ -2384,12 +2226,14 @@ impl Vm { if allow_jit && self.jit_config().enabled - && self.builtin_overrides.is_empty() + && self.host.allow_default_builtin_capabilities + && self.host.allow_default_host_capabilities + && self.host.builtin_overrides.is_empty() && !self.drop_contract_events_enabled() && !self.active_frame_has_shared_capture_cells() { let frame_key = self.active_frame_key(); - let trace_id = if self.jit.callable_frame_is_blocked(frame_key) { + let trace_id = if self.engine.jit.callable_frame_is_blocked(frame_key) { None } else { let stack_depth = self.active_operand_stack_len(); @@ -2397,9 +2241,9 @@ impl Vm { .then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - self.jit.observe_hot_entry_with_local_types( + self.engine.jit.observe_hot_entry_with_local_types( frame_key, - self.ip, + self.instance.ip, stack_depth, entry_local_types.as_deref(), entry_callable_prototypes.as_deref(), @@ -2432,7 +2276,7 @@ impl Vm { } } - if self.ip >= self.program.code.len() { + if self.instance.ip >= self.program.code.len() { return Err(VmError::BytecodeBounds); } @@ -2491,9 +2335,9 @@ impl Vm { x if x == OpCode::Nop as u8 => {} x if x == OpCode::Ret as u8 => return self.complete_active_frame(), x if x == OpCode::Ldc as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let value = if let Some(value) = self.decoded_ldc_value_at(opcode_ip).cloned() { - self.ip += 4; + self.instance.ip += 4; value } else { let index = self.read_u32()?; @@ -2503,10 +2347,10 @@ impl Vm { .cloned() .ok_or(VmError::InvalidConstant(index))? }; - self.stack.push(value); + self.instance.stack.push(value); } x if x == OpCode::Add as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2531,7 +2375,7 @@ impl Vm { } } x if x == OpCode::Sub as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2551,7 +2395,7 @@ impl Vm { } } x if x == OpCode::Mul as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2571,7 +2415,7 @@ impl Vm { } } x if x == OpCode::Div as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2590,20 +2434,22 @@ impl Vm { x if x == OpCode::Shl as u8 => { let rhs = self.pop_shift_amount()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(lhs.wrapping_shl(rhs))); + self.instance.stack.push(Value::Int(lhs.wrapping_shl(rhs))); } x if x == OpCode::Shr as u8 => { let rhs = self.pop_shift_amount()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(lhs.wrapping_shr(rhs))); + self.instance.stack.push(Value::Int(lhs.wrapping_shr(rhs))); } x if x == OpCode::Lshr as u8 => { let rhs = self.pop_shift_amount()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(logical_shr_i64(lhs, rhs))); + self.instance + .stack + .push(Value::Int(logical_shr_i64(lhs, rhs))); } x if x == OpCode::Mod as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2622,16 +2468,16 @@ impl Vm { x if x == OpCode::And as u8 => { let rhs = self.pop_bool()?; let lhs = self.pop_bool()?; - self.stack.push(Value::Bool(lhs && rhs)); + self.instance.stack.push(Value::Bool(lhs && rhs)); } x if x == OpCode::Or as u8 => { let rhs = self.pop_bool()?; let lhs = self.pop_bool()?; - self.stack.push(Value::Bool(lhs || rhs)); + self.instance.stack.push(Value::Bool(lhs || rhs)); } x if x == OpCode::Not as u8 => self.unary_not_op()?, x if x == OpCode::Neg as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_UNARY_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2645,15 +2491,17 @@ impl Vm { self.record_operand_hint_miss(); match self.pop_numeric()? { NumericValue::Int(value) => { - self.stack.push(Value::Int(value.wrapping_neg())) + self.instance.stack.push(Value::Int(value.wrapping_neg())) + } + NumericValue::Float(value) => { + self.instance.stack.push(Value::Float(-value)) } - NumericValue::Float(value) => self.stack.push(Value::Float(-value)), } } } } x if x == OpCode::Ceq as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2679,12 +2527,12 @@ impl Vm { self.record_operand_hint_miss(); let rhs = self.pop_value()?; let lhs = self.pop_value()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); } } } x if x == OpCode::Clt as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2701,7 +2549,7 @@ impl Vm { } } x if x == OpCode::Cgt as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2718,23 +2566,23 @@ impl Vm { } } x if x == OpCode::Br as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let target = if let Some(target) = self.decoded_jump_target_at(opcode_ip) { - self.ip += 4; + self.instance.ip += 4; target } else { self.read_u32()? as usize }; if self.decoded_jump_target_is_valid_at(opcode_ip) { - self.ip = target; + self.instance.ip = target; } else { self.jump_to(target)?; } } x if x == OpCode::Brfalse as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let target = if let Some(target) = self.decoded_jump_target_at(opcode_ip) { - self.ip += 4; + self.instance.ip += 4; target } else { self.read_u32()? as usize @@ -2742,7 +2590,7 @@ impl Vm { let condition = self.pop_bool()?; if !condition { if self.decoded_jump_target_is_valid_at(opcode_ip) { - self.ip = target; + self.instance.ip = target; } else { self.jump_to(target)?; } @@ -2753,12 +2601,12 @@ impl Vm { } x if x == OpCode::Dup as u8 => { let value = self.peek_value()?.clone(); - self.stack.push(value); + self.instance.stack.push(value); } x if x == OpCode::Ldloc as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let index = if let Some(index) = self.decoded_local_index_at(opcode_ip) { - self.ip += 1; + self.instance.ip += 1; index } else { self.read_u8()? @@ -2767,12 +2615,12 @@ impl Vm { return Ok(ExecOutcome::Continue); } let value = self.load_local_value(index)?; - self.stack.push(value); + self.instance.stack.push(value); } x if x == OpCode::Stloc as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let index = if let Some(index) = self.decoded_local_index_at(opcode_ip) { - self.ip += 1; + self.instance.ip += 1; index } else { self.read_u8()? @@ -2781,7 +2629,7 @@ impl Vm { self.store_local_with_drop_contract(index, value)?; } x if x == OpCode::Call as u8 => { - let call_ip = self.ip - 1; + let call_ip = self.instance.ip - 1; let index = self.read_u16()?; let argc_u8 = self.read_u8()?; let can_fuse_tail_halt = self.can_fuse_call_ret_pattern(); @@ -2791,13 +2639,13 @@ impl Vm { if self.interruption_enabled() { self.charge_interrupt_tick()?; } - self.ip = self.ip.saturating_add(1); + self.instance.ip = self.instance.ip.saturating_add(1); return self.complete_active_frame(); } } HostCallExecOutcome::Halted => return Ok(ExecOutcome::Halted), HostCallExecOutcome::Yielded => { - self.last_yield_reason = Some(VmYieldReason::Host); + self.instance.last_yield_reason = Some(VmYieldReason::Host); return Ok(ExecOutcome::Yielded); } HostCallExecOutcome::Pending(op_id) => return Ok(ExecOutcome::Waiting(op_id)), @@ -2805,7 +2653,7 @@ impl Vm { } x if x == OpCode::CallValue as u8 => { - let call_ip = self.ip.saturating_sub(1); + let call_ip = self.instance.ip.saturating_sub(1); let argc = self.read_u8()?; return self.execute_call_value(argc, Some(call_ip)); } @@ -2816,7 +2664,8 @@ impl Vm { pub fn resume(&mut self) -> VmResult { let allow_jit = !matches!( - self.execution_frames + self.instance + .execution_frames .last() .map(|frame| &frame.continuation), Some(FrameContinuation::ReturnToHost) @@ -2825,16 +2674,16 @@ impl Vm { } pub fn stack(&self) -> &[Value] { - &self.stack + &self.instance.stack } pub fn locals(&self) -> &[Value] { - &self.locals + &self.instance.locals } pub fn set_local(&mut self, index: u8, value: Value) -> VmResult<()> { self.store_local_with_drop_contract(index, value)?; - let config = *self.jit.config(); + let config = *self.engine.jit.config(); self.set_jit_config(config); Ok(()) } @@ -2844,22 +2693,22 @@ impl Vm { } pub fn bound_function_count(&self) -> usize { - self.host_functions.len() + self.host.host_functions.len() } pub fn has_bound_function(&self, name: &str) -> bool { - self.host_function_symbols.contains_key(name) + self.host.host_function_symbols.contains_key(name) } pub fn ip(&self) -> usize { - self.ip + self.instance.ip } pub(super) fn owns_callable(&self, value: &Value) -> bool { let Value::Callable(target) = value else { return false; }; - self.owned_callables.iter().any(|owned| { + self.instance.owned_callables.iter().any(|owned| { owned .upgrade() .is_some_and(|owned| Arc::ptr_eq(&owned, target)) @@ -2876,6 +2725,7 @@ impl Vm { VmError::HostError(format!("unknown exported script function '{name}'")) })?; let value = self + .instance .locals .get(exported.local_slot as usize) .cloned() @@ -2896,7 +2746,7 @@ impl Vm { } pub fn call_depth(&self) -> usize { - self.call_depth + self.instance.call_depth } pub fn queue_callable(&mut self, callable: Value, args: Vec) -> VmResult<()> { @@ -2909,13 +2759,13 @@ impl Vm { args: Vec, subscription: Option>, ) -> VmResult<()> { - if self.shutdown { + if self.instance.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } if !matches!(&callable, Value::Callable(_)) { return Err(VmError::InvalidCallable); } - self.queued_callables.push_back(QueuedCallable { + self.instance.queued_callables.push_back(QueuedCallable { callable, args, subscription, @@ -2924,23 +2774,23 @@ impl Vm { } pub fn queued_callable_count(&self) -> usize { - self.queued_callables.len() + self.instance.queued_callables.len() } pub fn drain_callable_queue(&mut self) -> VmResult> { - if self.draining_queued_callables { + if self.instance.draining_queued_callables { return Err(VmError::InvalidFrameState( "callable queue is already being drained", )); } - if !self.execution_frames.is_empty() { + if !self.instance.execution_frames.is_empty() { return Err(VmError::InvalidFrameState( "queued callables can only run after the root frame halts", )); } - self.draining_queued_callables = true; - let mut results = Vec::with_capacity(self.queued_callables.len()); - while let Some(queued) = self.queued_callables.pop_front() { + self.instance.draining_queued_callables = true; + let mut results = Vec::with_capacity(self.instance.queued_callables.len()); + while let Some(queued) = self.instance.queued_callables.pop_front() { if queued .subscription .as_ref() @@ -2950,9 +2800,9 @@ impl Vm { } match self.start_callable(queued.callable, &queued.args) { Ok(VmStatus::Halted) => { - let Some(result) = self.host_return.take() else { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + let Some(result) = self.instance.host_return.take() else { + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(VmError::InvalidFrameState( "queued invocation completed without a result", )); @@ -2960,84 +2810,78 @@ impl Vm { results.push(result); } Ok(VmStatus::Yielded) => { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(VmError::InvalidFrameState( "queued invocation yielded; resume it before draining again", )); } Ok(VmStatus::Waiting(_)) => { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(VmError::InvalidFrameState( "queued invocation is waiting; resume it before draining again", )); } Err(err) => { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(err); } } } - self.draining_queued_callables = false; + self.instance.draining_queued_callables = false; Ok(results) } pub fn shutdown(&mut self) { self.invalidate_callback_registries(); self.cancel_waiting_host_op(); - self.queued_callables.clear(); - self.completed_callable_results.clear(); - self.owned_callables.clear(); - self.draining_queued_callables = false; + self.instance.queued_callables.clear(); + self.instance.completed_callable_results.clear(); + self.instance.owned_callables.clear(); + self.instance.draining_queued_callables = false; self.clear_stack_with_drop_contract(); - self.capture_cells.clear(); - self.shared_capture_slots.clear(); + self.instance.capture_cells.clear(); + self.instance.shared_capture_slots.clear(); self.clear_locals_with_drop_contract(); - self.execution_frames.clear(); - self.active_local_base_cache = 0; - self.active_operand_stack_base_cache = 0; - self.call_depth = 0; - self.host_return = None; - self.waiting_host_op = None; + self.instance.execution_frames.clear(); + self.instance.active_local_base_cache = 0; + self.instance.active_operand_stack_base_cache = 0; + self.instance.call_depth = 0; + self.instance.host_return = None; + self.instance.waiting_host_op = None; crate::builtins::runtime::close_all_handles(self); - self.shutdown = true; + self.instance.shutdown = true; } pub(super) fn register_callback_registry(&mut self, active: &Arc) { - self.callback_registry_flags.push(Arc::downgrade(active)); + self.instance.register_callback_registry(active); } fn invalidate_callback_registries(&mut self) { - for active in self - .callback_registry_flags - .drain(..) - .filter_map(|flag| flag.upgrade()) - { - active.store(false, Ordering::Release); - } + self.instance.invalidate_callback_registries(); } pub fn start_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { - if self.shutdown { + if self.instance.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } if !matches!(&callable, Value::Callable(_)) { return Err(VmError::InvalidCallable); } - if !self.execution_frames.is_empty() { + if !self.instance.execution_frames.is_empty() { return Err(VmError::InvalidFrameState( "host invocation requires a halted VM", )); } let argc = u8::try_from(args.len()) .map_err(|_| VmError::InvalidFrameState("too many arguments"))?; - let stack_base = self.stack.len(); - let frame_count = self.execution_frames.len(); - self.stack.push(callable); - self.stack.extend_from_slice(args); - self.host_return = None; + let stack_base = self.instance.stack.len(); + let frame_count = self.instance.execution_frames.len(); + self.instance.stack.push(callable); + self.instance.stack.extend_from_slice(args); + self.instance.host_return = None; let outcome = match self.execute_call_value(argc, None) { Ok(outcome) => outcome, Err(error) => { @@ -3045,10 +2889,10 @@ impl Vm { return Err(error); } }; - if self.execution_frames.len() == frame_count { + if self.instance.execution_frames.len() == frame_count { let result = match outcome { ExecOutcome::Continue | ExecOutcome::Halted => { - self.stack.pop().unwrap_or(Value::Null) + self.instance.stack.pop().unwrap_or(Value::Null) } ExecOutcome::Yielded => { self.abort_host_invocation(stack_base, frame_count); @@ -3063,11 +2907,11 @@ impl Vm { )); } }; - self.stack.truncate(stack_base); - self.host_return = Some(result); + self.instance.stack.truncate(stack_base); + self.instance.host_return = Some(result); return Ok(VmStatus::Halted); } - if let Some(frame) = self.execution_frames.last_mut() { + if let Some(frame) = self.instance.execution_frames.last_mut() { frame.continuation = FrameContinuation::ReturnToHost; } match self.run_internal(None, false) { @@ -3080,12 +2924,16 @@ impl Vm { } pub fn invoke_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { - let stack_base = self.stack.len(); - let frame_count = self.execution_frames.len(); + let stack_base = self.instance.stack.len(); + let frame_count = self.instance.execution_frames.len(); match self.start_callable(callable, args)? { - VmStatus::Halted => self.host_return.take().ok_or(VmError::InvalidFrameState( - "host invocation completed without a result", - )), + VmStatus::Halted => self + .instance + .host_return + .take() + .ok_or(VmError::InvalidFrameState( + "host invocation completed without a result", + )), VmStatus::Yielded => { self.abort_host_invocation(stack_base, frame_count); Err(VmError::InvalidFrameState("host invocation yielded")) @@ -3098,53 +2946,64 @@ impl Vm { } fn abort_host_invocation(&mut self, stack_base: usize, frame_count: usize) { - while self.execution_frames.len() > frame_count { - let Some(frame) = self.execution_frames.pop() else { + while self.instance.execution_frames.len() > frame_count { + let Some(frame) = self.instance.execution_frames.pop() else { break; }; let frame_end = frame.local_base.saturating_add(frame.local_count); - self.capture_cells + self.instance + .capture_cells .retain(|absolute, _| *absolute < frame.local_base || *absolute >= frame_end); - self.shared_capture_slots + self.instance + .shared_capture_slots .retain(|absolute| *absolute < frame.local_base || *absolute >= frame_end); - if frame.local_base <= self.locals.len() { - let drained = self.locals.drain(frame.local_base..).collect::>(); + if frame.local_base <= self.instance.locals.len() { + let drained = self + .instance + .locals + .drain(frame.local_base..) + .collect::>(); for value in drained { self.drop_value_with_contract(value); } } } - self.active_local_base_cache = self + self.instance.active_local_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.local_base) .unwrap_or(0); - self.active_operand_stack_base_cache = self + self.instance.active_operand_stack_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.operand_stack_base) .unwrap_or(0); - while self.stack.len() > stack_base { - if let Some(value) = self.stack.pop() { + while self.instance.stack.len() > stack_base { + if let Some(value) = self.instance.stack.pop() { self.drop_value_with_contract(value); } } - self.call_depth = self.script_frame_depth(); - self.host_return = None; + self.instance.call_depth = self.script_frame_depth(); + self.instance.host_return = None; self.cancel_waiting_host_op(); - self.last_yield_reason = None; - self.map_iterators - .truncate(self.call_depth.saturating_add(1)); + self.instance.last_yield_reason = None; + self.instance + .map_iterators + .truncate(self.instance.call_depth.saturating_add(1)); } pub fn take_callable_result(&mut self) -> Option { - self.completed_callable_results + self.instance + .completed_callable_results .pop_front() - .or_else(|| self.host_return.take()) + .or_else(|| self.instance.host_return.take()) } pub fn execution_frames(&self) -> Vec { - self.execution_frames + self.instance + .execution_frames .iter() .map(|frame| VmExecutionFrameSnapshot { continuation: match frame.continuation { diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index ff692ba3..357f6b21 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -393,12 +393,12 @@ pub(crate) fn non_yielding_i64_host_call_entry_address() -> usize { } pub(crate) fn helper_entry_offset() -> i32 { - i32::try_from(std::mem::offset_of!(Vm, native_helper_fn)) + i32::try_from(std::mem::offset_of!(Vm, engine.native_helper_fn)) .expect("Vm::native_helper_fn offset must fit i32") } pub(crate) fn interrupt_helper_entry_offset() -> i32 { - i32::try_from(std::mem::offset_of!(Vm, native_interrupt_helper_fn)) + i32::try_from(std::mem::offset_of!(Vm, engine.native_interrupt_helper_fn)) .expect("Vm::native_interrupt_helper_fn offset must fit i32") } @@ -780,10 +780,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_exit_state( ip: usize, ) -> i32 { run_step(vm, "restore_exit_state", |vm| { - if locals_len != vm.locals.len() { + if locals_len != vm.instance.locals.len() { return Err(VmError::JitNative(format!( "native exit restore locals length mismatch: expected {}, got {}", - vm.locals.len(), + vm.instance.locals.len(), locals_len ))); } @@ -799,10 +799,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_exit_state( } vm.clear_stack_with_drop_contract(); - vm.stack.reserve(stack_len); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } for index in 0..locals_len { @@ -819,13 +819,14 @@ pub(crate) extern "C" fn pd_vm_native_restore_exit_state( } fn native_frame_state(vm: &Vm) -> VmResult { - let frame = vm.execution_frames.last(); + let frame = vm.instance.execution_frames.last(); let operand_stack_base = frame.map(|frame| frame.operand_stack_base).unwrap_or(0); let local_base = frame.map(|frame| frame.local_base).unwrap_or(0); let local_count = frame .map(|frame| frame.local_count) - .unwrap_or(vm.locals.len()); + .unwrap_or(vm.instance.locals.len()); let active_stack_len = vm + .instance .stack .len() .checked_sub(operand_stack_base) @@ -847,7 +848,7 @@ fn native_frame_state(vm: &Vm) -> VmResult { active_stack_len, local_base, local_count, - frame_depth: vm.call_depth, + frame_depth: vm.instance.call_depth, continuation_kind, }) } @@ -900,7 +901,7 @@ fn write_inherited_state_packet(vm: &Vm, packet: *mut u8) -> VmResult<()> { packet .add(INHERITED_STATE_TARGET_IP_OFFSET as usize) .cast::() - .write(vm.ip); + .write(vm.instance.ip); packet .add(INHERITED_STATE_VALUE_COUNT_OFFSET as usize) .cast::() @@ -908,11 +909,11 @@ fn write_inherited_state_packet(vm: &Vm, packet: *mut u8) -> VmResult<()> { let values = packet .add(INHERITED_STATE_VALUES_OFFSET as usize) .cast::<*const Value>(); - let stack = vm.stack.as_ptr().add(state.operand_stack_base); + let stack = vm.instance.stack.as_ptr().add(state.operand_stack_base); for index in 0..state.active_stack_len { values.add(index).write(stack.add(index)); } - let locals = vm.locals.as_ptr().add(state.local_base); + let locals = vm.instance.locals.as_ptr().add(state.local_base); for index in 0..state.local_count { values .add(state.active_stack_len + index) @@ -957,13 +958,13 @@ fn native_enter_call_value( .map_err(|_| VmError::InvalidFrameState("native call ip out of range"))?; let resume_ip = usize::try_from(resume_ip) .map_err(|_| VmError::InvalidFrameState("native resume ip out of range"))?; - if vm.ip != call_ip { + if vm.instance.ip != call_ip { vm.jump_to(call_ip)?; } if resume_ip > vm.program.code.len() { return Err(VmError::BytecodeBounds); } - vm.ip = resume_ip; + vm.instance.ip = resume_ip; let status = match vm.execute_call_value(argc, Some(call_ip))? { ExecOutcome::Continue => STATUS_LINKED_CONTINUE, ExecOutcome::Halted => STATUS_HALTED, @@ -1067,17 +1068,17 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_exit_state( let expected_locals_len = local_base .checked_add(locals_len) .ok_or_else(|| VmError::JitNative("native active local length overflow".to_string()))?; - if expected_locals_len != vm.locals.len() { + if expected_locals_len != vm.instance.locals.len() { return Err(VmError::JitNative(format!( "native active exit restore locals length mismatch: expected {}, got {}", - vm.locals.len(), + vm.instance.locals.len(), expected_locals_len ))); } - if stack_base > vm.stack.len() { + if stack_base > vm.instance.stack.len() { return Err(VmError::JitNative(format!( "native active stack base {stack_base} exceeds stack length {}", - vm.stack.len() + vm.instance.stack.len() ))); } if stack_len != 0 && stack_src.is_null() { @@ -1091,11 +1092,11 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_exit_state( )); } - vm.stack.truncate(stack_base); - vm.stack.reserve(stack_len); + vm.instance.stack.truncate(stack_base); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } for index in 0..locals_len { @@ -1145,10 +1146,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_sparse_exit_state( "native sparse exit restore local index out of range".to_string(), ) })?; - if local_index_usize >= vm.locals.len() { + if local_index_usize >= vm.instance.locals.len() { return Err(VmError::JitNative(format!( "native sparse exit restore local index {local_index} out of range for {} locals", - vm.locals.len() + vm.instance.locals.len() ))); } let local_index = u8::try_from(local_index).map_err(|_| { @@ -1165,10 +1166,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_sparse_exit_state( } vm.clear_stack_with_drop_contract(); - vm.stack.reserve(stack_len); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } for (compact_index, local_index) in validated_indices.into_iter().enumerate() { @@ -1212,29 +1213,29 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_sparse_exit_state( // while the sparse exit metadata is built. let stack_base = vm.active_operand_stack_base(); - if stack_base > vm.stack.len() { + if stack_base > vm.instance.stack.len() { return Err(VmError::JitNative(format!( "native active sparse stack base {stack_base} exceeds stack length {}", - vm.stack.len() + vm.instance.stack.len() ))); } - vm.stack.truncate(stack_base); - vm.stack.reserve(stack_len); + vm.instance.stack.truncate(stack_base); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } - if vm.capture_cells.is_empty() { + if vm.instance.capture_cells.is_empty() { let local_base = vm.active_local_base(); - let count_drop_events = vm.drop_contract_events_enabled; + let count_drop_events = vm.instance.drop_contract_events_enabled; for compact_index in 0..dirty_local_count { let local_index = unsafe { *dirty_local_indices.add(compact_index) } as usize; debug_assert!(local_index < 256); let absolute = local_base + local_index; - debug_assert!(absolute < vm.locals.len()); + debug_assert!(absolute < vm.instance.locals.len()); let value = unsafe { std::ptr::read(dirty_local_values.add(compact_index)) }; - let slot = unsafe { vm.locals.get_unchecked_mut(absolute) }; + let slot = unsafe { vm.instance.locals.get_unchecked_mut(absolute) }; let previous = std::mem::replace(slot, value); if count_drop_events { vm.count_value_drop_contract(&previous); @@ -1253,7 +1254,7 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_sparse_exit_state( if ip >= vm.program.code.len() { return Err(VmError::InvalidBranchTarget { target: ip }); } - vm.ip = ip; + vm.instance.ip = ip; Ok(STATUS_CONTINUE) }) } @@ -1281,9 +1282,9 @@ pub(crate) extern "C" fn pd_vm_native_restore_virtual_frame( "virtual frame restore received null locals buffer".to_string(), )); } - if vm.call_depth >= vm.max_script_call_depth { + if vm.instance.call_depth >= vm.instance.max_script_call_depth { return Err(VmError::CallStackOverflow { - limit: vm.max_script_call_depth, + limit: vm.instance.max_script_call_depth, }); } let prototype = vm @@ -1326,29 +1327,31 @@ pub(crate) extern "C" fn pd_vm_native_restore_virtual_frame( )); } - let operand_stack_base = vm.stack.len(); - let local_base = vm.locals.len(); - vm.stack.reserve(stack_len); - vm.locals.reserve(locals_len); + let operand_stack_base = vm.instance.stack.len(); + let local_base = vm.instance.locals.len(); + vm.instance.stack.reserve(stack_len); + vm.instance.locals.reserve(locals_len); for index in 0..stack_len { - vm.stack + vm.instance + .stack .push(unsafe { std::ptr::read(stack_src.add(index)) }); } for index in 0..locals_len { - vm.locals + vm.instance + .locals .push(unsafe { std::ptr::read(locals_src.add(index)) }); } - vm.execution_frames.push(ExecutionFrame { + vm.instance.execution_frames.push(ExecutionFrame { continuation: FrameContinuation::ResumeBytecode { return_ip }, operand_stack_base, local_base, local_count: locals_len, prototype_id: Some(prototype_id), }); - vm.active_local_base_cache = local_base; - vm.active_operand_stack_base_cache = operand_stack_base; - vm.call_depth = vm.script_frame_depth(); - vm.ip = resume_ip; + vm.instance.active_local_base_cache = local_base; + vm.instance.active_operand_stack_base_cache = operand_stack_base; + vm.instance.call_depth = vm.script_frame_depth(); + vm.instance.ip = resume_ip; Ok(STATUS_CONTINUE) }) } @@ -1662,10 +1665,11 @@ fn call_non_yielding_host_value( expected_return_type: Option, ) -> VmResult { let resolved = *vm + .host .resolved_calls .get(import) .ok_or(VmError::InvalidCall(import as u16))?; - let function = match vm.host_functions.get(usize::from(resolved)) { + let function = match vm.host.host_functions.get(usize::from(resolved)) { Some(VmHostFunction::ArgsStaticNonYielding(function)) => *function, _ => { return Err(VmError::JitNative( @@ -1673,9 +1677,9 @@ fn call_non_yielding_host_value( )); } }; - vm.call_depth = vm.call_depth.saturating_add(1); + vm.instance.call_depth = vm.instance.call_depth.saturating_add(1); let outcome = function(args); - vm.call_depth = vm.call_depth.saturating_sub(1); + 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| { @@ -1809,7 +1813,7 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, .get(index as usize) .cloned() .ok_or(VmError::InvalidConstant(index))?; - vm.stack.push(value); + vm.instance.stack.push(value); Ok(STATUS_CONTINUE) } OP_ADD => { @@ -1841,34 +1845,41 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, OP_SHL => { let rhs = vm.pop_shift_amount()?; let lhs = vm.pop_int()?; - vm.stack + vm.instance + .stack .push(crate::bytecode::Value::Int(lhs.wrapping_shl(rhs))); Ok(STATUS_CONTINUE) } OP_SHR => { let rhs = vm.pop_shift_amount()?; let lhs = vm.pop_int()?; - vm.stack + vm.instance + .stack .push(crate::bytecode::Value::Int(lhs.wrapping_shr(rhs))); Ok(STATUS_CONTINUE) } OP_LSHR => { let rhs = vm.pop_shift_amount()?; let lhs = vm.pop_int()?; - vm.stack + vm.instance + .stack .push(crate::bytecode::Value::Int(logical_shr_i64(lhs, rhs))); Ok(STATUS_CONTINUE) } OP_AND => { let rhs = vm.pop_bool()?; let lhs = vm.pop_bool()?; - vm.stack.push(crate::bytecode::Value::Bool(lhs && rhs)); + vm.instance + .stack + .push(crate::bytecode::Value::Bool(lhs && rhs)); Ok(STATUS_CONTINUE) } OP_OR => { let rhs = vm.pop_bool()?; let lhs = vm.pop_bool()?; - vm.stack.push(crate::bytecode::Value::Bool(lhs || rhs)); + vm.instance + .stack + .push(crate::bytecode::Value::Bool(lhs || rhs)); Ok(STATUS_CONTINUE) } OP_NOT => { @@ -1879,18 +1890,22 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, let value = vm.pop_numeric()?; match value { NumericValue::Int(value) => vm + .instance .stack .push(crate::bytecode::Value::Int(value.wrapping_neg())), - NumericValue::Float(value) => { - vm.stack.push(crate::bytecode::Value::Float(-value)) - } + NumericValue::Float(value) => vm + .instance + .stack + .push(crate::bytecode::Value::Float(-value)), } Ok(STATUS_CONTINUE) } OP_CEQ => { let rhs = vm.pop_value()?; let lhs = vm.pop_value()?; - vm.stack.push(crate::bytecode::Value::Bool(lhs == rhs)); + vm.instance + .stack + .push(crate::bytecode::Value::Bool(lhs == rhs)); Ok(STATUS_CONTINUE) } OP_CLT => { @@ -1907,18 +1922,19 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, } OP_DUP => { let value = vm.peek_value()?.clone(); - vm.stack.push(value); + vm.instance.stack.push(value); Ok(STATUS_CONTINUE) } OP_LDLOC => { let index = u8::try_from(a) .map_err(|_| VmError::JitNative("ldloc index out of range".to_string()))?; let value = vm + .instance .locals .get(index as usize) .cloned() .ok_or(VmError::InvalidLocal(index))?; - vm.stack.push(value); + vm.instance.stack.push(value); Ok(STATUS_CONTINUE) } OP_STLOC => { @@ -2046,11 +2062,11 @@ mod tests { let mut vm = Vm::new(virtual_frame_program()); let locals = [Value::Int(7)]; let before = ( - vm.ip, - vm.stack.len(), - vm.locals.len(), - vm.execution_frames.len(), - vm.call_depth, + vm.instance.ip, + vm.instance.stack.len(), + vm.instance.locals.len(), + vm.instance.execution_frames.len(), + vm.instance.call_depth, ); let status = pd_vm_native_restore_virtual_frame( &mut vm, @@ -2067,11 +2083,11 @@ mod tests { assert_eq!( before, ( - vm.ip, - vm.stack.len(), - vm.locals.len(), - vm.execution_frames.len(), - vm.call_depth, + vm.instance.ip, + vm.instance.stack.len(), + vm.instance.locals.len(), + vm.instance.execution_frames.len(), + vm.instance.call_depth, ) ); let _ = take_bridge_error(); @@ -2093,11 +2109,11 @@ mod tests { locals.len(), ); assert_eq!(status, STATUS_CONTINUE); - assert_eq!(vm.ip, 2); - assert_eq!(vm.call_depth, 1); - assert_eq!(vm.execution_frames.len(), 2); - assert_eq!(vm.locals.last(), Some(&Value::Int(7))); - let frame = vm.execution_frames.last().unwrap(); + assert_eq!(vm.instance.ip, 2); + assert_eq!(vm.instance.call_depth, 1); + assert_eq!(vm.instance.execution_frames.len(), 2); + assert_eq!(vm.instance.locals.last(), Some(&Value::Int(7))); + let frame = vm.instance.execution_frames.last().unwrap(); assert_eq!(frame.prototype_id, Some(0)); assert_eq!(frame.local_count, 1); assert_eq!( @@ -2135,24 +2151,26 @@ mod tests { let program = crate::Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]).with_local_count(2); let mut vm = Vm::new(program); - vm.stack = vec![Value::Int(10), Value::Int(20)]; - vm.locals = vec![ + vm.instance.stack = vec![Value::Int(10), Value::Int(20)]; + vm.instance.locals = vec![ Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4), Value::Int(5), ]; - vm.execution_frames.push(crate::vm::ExecutionFrame { - continuation: FrameContinuation::ResumeBytecode { return_ip: 0 }, - operand_stack_base: 1, - local_base: 2, - local_count: 3, - prototype_id: Some(7), - }); - vm.active_local_base_cache = 2; - vm.active_operand_stack_base_cache = 1; - vm.call_depth = 1; + vm.instance + .execution_frames + .push(crate::vm::ExecutionFrame { + continuation: FrameContinuation::ResumeBytecode { return_ip: 0 }, + operand_stack_base: 1, + local_base: 2, + local_count: 3, + prototype_id: Some(7), + }); + vm.instance.active_local_base_cache = 2; + vm.instance.active_operand_stack_base_cache = 1; + vm.instance.call_depth = 1; let mut state = MaybeUninit::::uninit(); assert_eq!( @@ -2188,9 +2206,9 @@ mod tests { ); std::mem::forget(stack); std::mem::forget(locals); - assert_eq!(vm.stack, vec![Value::Int(10), Value::Int(99)]); + assert_eq!(vm.instance.stack, vec![Value::Int(10), Value::Int(99)]); assert_eq!( - vm.locals, + vm.instance.locals, vec![ Value::Int(1), Value::Int(2), @@ -2218,11 +2236,11 @@ mod tests { std::mem::forget(sparse_stack); std::mem::forget(dirty_values); assert_eq!( - vm.stack, + vm.instance.stack, vec![Value::Int(10), Value::Int(77), Value::Int(88)] ); assert_eq!( - vm.locals, + vm.instance.locals, vec![ Value::Int(1), Value::Int(2), @@ -2265,22 +2283,22 @@ mod tests { .expect("bind callable"); assert!(matches!(callable, Value::Callable(_))); - vm.stack.extend([callable, Value::Int(41)]); + vm.instance.stack.extend([callable, Value::Int(41)]); assert_eq!( pd_vm_native_enter_call_value(&mut vm, 1, call_ip as i64, resume_ip as i64,), STATUS_LINKED_CONTINUE ); - assert_eq!(vm.call_depth, 1); - assert_eq!(vm.ip, function.entry_ip as usize); + assert_eq!(vm.instance.call_depth, 1); + assert_eq!(vm.instance.ip, function.entry_ip as usize); - vm.stack.push(Value::Int(42)); + vm.instance.stack.push(Value::Int(42)); assert_eq!( pd_vm_native_leave_frame(&mut vm, ret_ip as i64), STATUS_LINKED_CONTINUE ); - assert_eq!(vm.call_depth, 0); - assert_eq!(vm.ip, resume_ip); - assert_eq!(vm.stack, vec![Value::Int(42)]); + assert_eq!(vm.instance.call_depth, 0); + assert_eq!(vm.instance.ip, resume_ip); + assert_eq!(vm.instance.stack, vec![Value::Int(42)]); } #[test] @@ -2314,7 +2332,7 @@ mod tests { vm.set_local(0, Value::Int(17)).expect("scalar local"); vm.set_local(1, Value::String(preserved.clone())) .expect("heap local"); - vm.stack.push(Value::Int(99)); + vm.instance.stack.push(Value::Int(99)); let status = pd_vm_native_restore_sparse_exit_state( &mut vm, @@ -2342,7 +2360,7 @@ mod tests { crate::Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]).with_local_count(1); let mut vm = Vm::new(program); vm.set_local(0, Value::Int(17)).expect("initial local"); - vm.stack.push(Value::Int(23)); + vm.instance.stack.push(Value::Int(23)); let local_value = Value::Int(99); let null_indices = pd_vm_native_restore_sparse_exit_state( diff --git a/src/vm/native/layout.rs b/src/vm/native/layout.rs index c41c95a5..ed357e12 100644 --- a/src/vm/native/layout.rs +++ b/src/vm/native/layout.rs @@ -56,6 +56,101 @@ pub(crate) struct NativeStackLayout { pub(crate) value: ValueLayout, } +pub(crate) fn native_stack_layout_fingerprint(layout: NativeStackLayout) -> u64 { + const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + + let NativeStackLayout { + vm_stack_offset, + vm_locals_offset, + vm_program_constants_ptr_offset, + vm_ip_offset, + vm_fuel_remaining_offset, + vm_fuel_ops_until_check_offset, + vm_epoch_deadline_offset, + vm_epoch_counter_ptr_offset, + vm_jit_native_region_edge_count_offset, + vm_jit_native_direct_link_count_offset, + vm_jit_native_active_direct_trace_id_offset, + stack_vec, + map, + value, + } = layout; + let VecLayout { + ptr_offset: stack_vec_ptr_offset, + len_offset: stack_vec_len_offset, + } = stack_vec; + let MapLayout { + len_offset: map_len_offset, + } = map; + let ValueLayout { + size: value_size, + tag_offset: value_tag_offset, + tag_size: value_tag_size, + null_tag, + int_tag, + float_tag, + bool_tag, + string_tag, + bytes_tag, + array_tag, + map_tag, + int_payload_offset, + float_payload_offset, + bool_payload_offset, + heap_payload_offset, + arc_data_offset, + } = value; + + let mut fingerprint = FNV_OFFSET_BASIS; + for byte in b"pd-vm-native-stack-layout-v1\0" { + fingerprint ^= u64::from(*byte); + fingerprint = fingerprint.wrapping_mul(FNV_PRIME); + } + + macro_rules! hash_field { + ($value:expr) => { + for byte in $value.to_le_bytes() { + fingerprint ^= u64::from(byte); + fingerprint = fingerprint.wrapping_mul(FNV_PRIME); + } + }; + } + + hash_field!(vm_stack_offset); + hash_field!(vm_locals_offset); + hash_field!(vm_program_constants_ptr_offset); + hash_field!(vm_ip_offset); + hash_field!(vm_fuel_remaining_offset); + hash_field!(vm_fuel_ops_until_check_offset); + hash_field!(vm_epoch_deadline_offset); + hash_field!(vm_epoch_counter_ptr_offset); + hash_field!(vm_jit_native_region_edge_count_offset); + hash_field!(vm_jit_native_direct_link_count_offset); + hash_field!(vm_jit_native_active_direct_trace_id_offset); + hash_field!(stack_vec_ptr_offset); + hash_field!(stack_vec_len_offset); + hash_field!(map_len_offset); + hash_field!(value_size); + hash_field!(value_tag_offset); + hash_field!(value_tag_size); + hash_field!(null_tag); + hash_field!(int_tag); + hash_field!(float_tag); + hash_field!(bool_tag); + hash_field!(string_tag); + hash_field!(bytes_tag); + hash_field!(array_tag); + hash_field!(map_tag); + hash_field!(int_payload_offset); + hash_field!(float_payload_offset); + hash_field!(bool_payload_offset); + hash_field!(heap_payload_offset); + hash_field!(arc_data_offset); + + fingerprint +} + pub(crate) fn detect_native_stack_layout() -> VmResult { let cached = NATIVE_STACK_LAYOUT .get_or_init(|| detect_native_stack_layout_uncached().map_err(layout_probe_error_message)); @@ -66,39 +161,43 @@ pub(crate) fn detect_native_stack_layout() -> VmResult { } fn detect_native_stack_layout_uncached() -> VmResult { - let vm_stack_offset = usize_to_i32(std::mem::offset_of!(Vm, stack), "Vm::stack offset")?; - let vm_locals_offset = usize_to_i32(std::mem::offset_of!(Vm, locals), "Vm::locals offset")?; + let vm_stack_offset = + usize_to_i32(std::mem::offset_of!(Vm, instance.stack), "Vm::stack offset")?; + let vm_locals_offset = usize_to_i32( + std::mem::offset_of!(Vm, instance.locals), + "Vm::locals offset", + )?; let vm_program_constants_ptr_offset = usize_to_i32( - std::mem::offset_of!(Vm, program_constants_ptr), + std::mem::offset_of!(Vm, engine.program_constants_ptr), "Vm::program_constants_ptr offset", )?; - let vm_ip_offset = usize_to_i32(std::mem::offset_of!(Vm, ip), "Vm::ip offset")?; + let vm_ip_offset = usize_to_i32(std::mem::offset_of!(Vm, instance.ip), "Vm::ip offset")?; let vm_fuel_remaining_offset = usize_to_i32( - std::mem::offset_of!(Vm, fuel_remaining), + std::mem::offset_of!(Vm, run_ctx.fuel_remaining), "Vm::fuel_remaining offset", )?; let vm_fuel_ops_until_check_offset = usize_to_i32( - std::mem::offset_of!(Vm, fuel_ops_until_check), + std::mem::offset_of!(Vm, run_ctx.fuel_ops_until_check), "Vm::fuel_ops_until_check offset", )?; let vm_epoch_deadline_offset = usize_to_i32( - std::mem::offset_of!(Vm, epoch_deadline), + std::mem::offset_of!(Vm, run_ctx.epoch_deadline), "Vm::epoch_deadline offset", )?; let vm_epoch_counter_ptr_offset = usize_to_i32( - std::mem::offset_of!(Vm, epoch_counter_ptr), + std::mem::offset_of!(Vm, run_ctx.epoch_counter_ptr), "Vm::epoch_counter_ptr offset", )?; let vm_jit_native_region_edge_count_offset = usize_to_i32( - std::mem::offset_of!(Vm, jit_native_region_edge_count), + std::mem::offset_of!(Vm, engine.jit_native_region_edge_count), "Vm::jit_native_region_edge_count offset", )?; let vm_jit_native_direct_link_count_offset = usize_to_i32( - std::mem::offset_of!(Vm, jit_native_direct_link_count), + std::mem::offset_of!(Vm, engine.jit_native_direct_link_count), "Vm::jit_native_direct_link_count offset", )?; let vm_jit_native_active_direct_trace_id_offset = usize_to_i32( - std::mem::offset_of!(Vm, jit_native_active_direct_trace_id), + std::mem::offset_of!(Vm, engine.jit_native_active_direct_trace_id), "Vm::jit_native_active_direct_trace_id offset", )?; let stack_vec = detect_vec_layout()?; @@ -508,3 +607,105 @@ fn usize_to_i32(value: usize, context: &str) -> VmResult { i32::try_from(value) .map_err(|_| VmError::JitNative(format!("{} exceeds 32-bit displacement range", context))) } + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_layout() -> NativeStackLayout { + NativeStackLayout { + vm_stack_offset: 1, + vm_locals_offset: 2, + vm_program_constants_ptr_offset: 3, + vm_ip_offset: 4, + vm_fuel_remaining_offset: 5, + vm_fuel_ops_until_check_offset: 6, + vm_epoch_deadline_offset: 7, + vm_epoch_counter_ptr_offset: 8, + vm_jit_native_region_edge_count_offset: 9, + vm_jit_native_direct_link_count_offset: 10, + vm_jit_native_active_direct_trace_id_offset: 11, + stack_vec: VecLayout { + ptr_offset: 12, + len_offset: 13, + }, + map: MapLayout { len_offset: 14 }, + value: ValueLayout { + size: 15, + tag_offset: 16, + tag_size: 17, + null_tag: 18, + int_tag: 19, + float_tag: 20, + bool_tag: 21, + string_tag: 22, + bytes_tag: 23, + array_tag: 24, + map_tag: 25, + int_payload_offset: 26, + float_payload_offset: 27, + bool_payload_offset: 28, + heap_payload_offset: 29, + arc_data_offset: 30, + }, + } + } + + #[test] + fn native_stack_layout_fingerprint_has_stable_encoding() { + assert_eq!( + native_stack_layout_fingerprint(fixture_layout()), + 0x5730_e57f_2190_5082 + ); + } + + #[test] + fn native_stack_layout_fingerprint_covers_every_embedded_field() { + let layout = fixture_layout(); + let original = native_stack_layout_fingerprint(layout); + + macro_rules! assert_field_is_covered { + ($($field:ident).+) => {{ + let mut changed = layout; + changed.$($field).+ = changed.$($field).+.wrapping_add(1); + assert_ne!( + native_stack_layout_fingerprint(changed), + original, + "{} must affect the native layout fingerprint", + stringify!($($field).+) + ); + }}; + } + + assert_field_is_covered!(vm_stack_offset); + assert_field_is_covered!(vm_locals_offset); + assert_field_is_covered!(vm_program_constants_ptr_offset); + assert_field_is_covered!(vm_ip_offset); + assert_field_is_covered!(vm_fuel_remaining_offset); + assert_field_is_covered!(vm_fuel_ops_until_check_offset); + assert_field_is_covered!(vm_epoch_deadline_offset); + assert_field_is_covered!(vm_epoch_counter_ptr_offset); + assert_field_is_covered!(vm_jit_native_region_edge_count_offset); + assert_field_is_covered!(vm_jit_native_direct_link_count_offset); + assert_field_is_covered!(vm_jit_native_active_direct_trace_id_offset); + assert_field_is_covered!(stack_vec.ptr_offset); + assert_field_is_covered!(stack_vec.len_offset); + assert_field_is_covered!(map.len_offset); + assert_field_is_covered!(value.size); + assert_field_is_covered!(value.tag_offset); + assert_field_is_covered!(value.tag_size); + assert_field_is_covered!(value.null_tag); + assert_field_is_covered!(value.int_tag); + assert_field_is_covered!(value.float_tag); + assert_field_is_covered!(value.bool_tag); + assert_field_is_covered!(value.string_tag); + assert_field_is_covered!(value.bytes_tag); + assert_field_is_covered!(value.array_tag); + assert_field_is_covered!(value.map_tag); + assert_field_is_covered!(value.int_payload_offset); + assert_field_is_covered!(value.float_payload_offset); + assert_field_is_covered!(value.bool_payload_offset); + assert_field_is_covered!(value.heap_payload_offset); + assert_field_is_covered!(value.arc_data_offset); + } +} diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index 86b536cb..41b12ae1 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -50,6 +50,7 @@ pub(crate) use codegen::{ pub(crate) use exec::{ExecutableBuffer, prepare_for_execution}; pub(crate) use layout::{ NativeStackLayout, ValueLayout, checked_add_i32, detect_native_stack_layout, + native_stack_layout_fingerprint, }; #[cfg(feature = "cranelift-jit")] pub(crate) use offsets::{HeapIntrinsicAddrs, HeapIntrinsicRefs, ResolvedOffsets, resolve_offsets}; diff --git a/src/vm/program.rs b/src/vm/program.rs new file mode 100644 index 00000000..10d34236 --- /dev/null +++ b/src/vm/program.rs @@ -0,0 +1,22 @@ +//! Immutable program artifact. +//! +//! [`Program`] is the compiled, immutable unit of +//! execution: bytecode, constants, metadata, import requirements, and +//! binding tables. This module documents its ownership contract for the VM +//! runtime decomposition: +//! +//! - A `Program` is immutable after compilation and binding metadata +//! construction; sharing one `Program` (e.g. through `Arc`) is the +//! only supported way to share code between VMs or instances. +//! - Per-run state (stacks, locals, frames, wait state) never lives in the +//! program; it lives in the VM's private `Instance` state. +//! - Backend caches derived from the program (decoded instruction data, +//! operand type hints, AOT/JIT artifacts) live in +//! the VM's private `Engine` state and are keyed by the program's cache +//! identity, never owned by a run. +//! +//! Thread safety: `Program` is `Send + Sync` and `Clone`-cheap only through +//! `Arc`; cloning the struct itself duplicates metadata, which is allowed but +//! wasteful. Prefer `Arc` for sharing. + +pub use crate::bytecode::Program; diff --git a/src/vm/run_context.rs b/src/vm/run_context.rs new file mode 100644 index 00000000..235a1c5a --- /dev/null +++ b/src/vm/run_context.rs @@ -0,0 +1,189 @@ +//! Run-scoped execution context. +//! +//! [`RunContext`] owns everything that belongs to one execution of a program: +//! the generic runtime input/event context, 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). +//! +//! 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. + +use crate::builtins::runtime::cancellation::{CancellationReason, CancellationToken}; +use crate::builtins::runtime::context::RuntimeContext; +use crate::vm::VmResult; +use crate::vm::epoch::EpochHandle; + +/// Run interruption mode: no budget, fuel metering, or epoch deadlines. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum InterruptMode { + None = 0, + Fuel = 1, + Epoch = 2, +} + +impl InterruptMode { + pub(crate) fn label(self) -> &'static str { + match self { + Self::None => "none", + Self::Fuel => "fuel", + Self::Epoch => "epoch", + } + } +} + +/// Run-scoped input, events, budgets, deadlines, and interruption state. +/// +/// Thread safety: `RunContext` is `!Sync` (event sink and counters are +/// mutable) and not shared; one facade owns one context. Clone semantics: +/// not `Clone` — a clone would duplicate event/input state across runs. +pub(crate) struct RunContext { + pub(crate) runtime_context: RuntimeContext, + pub(crate) cancellation: CancellationToken, + pub(crate) interrupt_mode: InterruptMode, + pub(crate) fuel_remaining: u64, + pub(crate) fuel_check_interval: u32, + pub(crate) fuel_ops_until_check: u32, + pub(crate) epoch_deadline: u64, + pub(crate) epoch_deadline_delta: u64, + pub(crate) epoch_rearm_pending: bool, + pub(crate) epoch_handle: EpochHandle, + // Native ABI mirror: the epoch counter address read by generated code. + // Load-bearing for `crate::vm::native`; see `crate::vm::engine`. + #[allow(dead_code)] + pub(crate) epoch_counter_ptr: usize, +} + +impl RunContext { + /// Creates a fresh run context with no input, no event sink, and no + /// budgets (interrupts disabled). + pub(crate) fn new() -> Self { + let epoch_handle = EpochHandle::default(); + let epoch_counter_ptr = epoch_handle.as_ptr() as usize; + Self { + runtime_context: RuntimeContext::default(), + cancellation: CancellationToken::root(), + interrupt_mode: InterruptMode::None, + fuel_remaining: 0, + fuel_check_interval: 1, + fuel_ops_until_check: 1, + epoch_deadline: 0, + epoch_deadline_delta: 0, + epoch_rearm_pending: false, + epoch_handle, + epoch_counter_ptr, + } + } + + /// Closes run-scoped state for reuse: input and events are cleared and + /// fuel/epoch budgets are dropped (metering disabled, no leftovers). + pub(crate) fn reset_for_reuse(&mut self) { + self.cancellation.cancel(CancellationReason::VmReset); + self.cancellation = CancellationToken::root(); + self.epoch_rearm_pending = false; + self.clear_fuel_internal(); + self.clear_epoch_deadline_internal(); + self.runtime_context.reset_for_reuse(); + } + + pub(crate) fn cancel(&self, reason: CancellationReason) -> VmResult<()> { + self.cancellation.cancel(reason); + match self.cancellation.take_propagation_error() { + Some(error) => Err(crate::vm::VmError::HostError(error.to_string())), + None => Ok(()), + } + } + + pub(crate) fn reset_interrupt_countdown(&mut self) { + self.fuel_ops_until_check = self.fuel_check_interval.max(1); + } + + pub(crate) fn clear_fuel_internal(&mut self) { + if self.interrupt_mode == InterruptMode::Fuel { + self.interrupt_mode = InterruptMode::None; + } + self.fuel_remaining = 0; + self.reset_interrupt_countdown(); + } + + pub(crate) fn clear_epoch_deadline_internal(&mut self) { + if self.interrupt_mode == InterruptMode::Epoch { + self.interrupt_mode = InterruptMode::None; + } + self.epoch_deadline = 0; + self.epoch_deadline_delta = 0; + self.epoch_rearm_pending = false; + self.reset_interrupt_countdown(); + } + + pub(crate) fn pending_fuel_debt(&self) -> u64 { + if self.interrupt_mode != InterruptMode::Fuel { + return 0; + } + let executed_since_last_check = self + .fuel_check_interval + .saturating_sub(self.fuel_ops_until_check); + u64::from(executed_since_last_check) + } + + /// Charges a fixed amount of fuel; errors when the budget is exhausted. + pub(crate) fn charge_fuel(&mut self, amount: u64) -> VmResult<()> { + if amount == 0 || self.interrupt_mode != InterruptMode::Fuel { + return Ok(()); + } + let remaining = self.fuel_remaining; + if remaining < amount { + return Err(crate::vm::VmError::OutOfFuel { + needed: amount, + remaining, + }); + } + self.fuel_remaining = remaining - amount; + Ok(()) + } + + /// Charges one fuel interval according to the countdown; errors when the + /// budget is exhausted. + pub(crate) fn charge_fuel_tick(&mut self) -> VmResult<()> { + if self.interrupt_mode != InterruptMode::Fuel { + return Ok(()); + } + if self.fuel_ops_until_check > 1 { + self.fuel_ops_until_check -= 1; + return Ok(()); + } + let amount = u64::from(self.fuel_check_interval); + self.charge_fuel(amount)?; + self.fuel_ops_until_check = self.fuel_check_interval; + Ok(()) + } + + /// Charges one epoch countdown tick; errors when the deadline passed. + pub(crate) fn charge_epoch_tick(&mut self) -> VmResult<()> { + if self.interrupt_mode != InterruptMode::Epoch { + return Ok(()); + } + if self.fuel_ops_until_check > 1 { + self.fuel_ops_until_check -= 1; + return Ok(()); + } + let current = self.epoch_handle.current(); + if current >= self.epoch_deadline { + return Err(crate::vm::VmError::EpochDeadlineReached { + current, + deadline: self.epoch_deadline, + }); + } + self.fuel_ops_until_check = self.fuel_check_interval; + Ok(()) + } +} + +impl Default for RunContext { + fn default() -> Self { + Self::new() + } +} diff --git a/src/vm/superinstructions.rs b/src/vm/superinstructions.rs index 47c8e581..e6e87f1f 100644 --- a/src/vm/superinstructions.rs +++ b/src/vm/superinstructions.rs @@ -46,7 +46,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_ldc_value_at(&self, opcode_ip: usize) -> Option<&Value> { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .ldc_values .get(opcode_ip) .and_then(|value| value.as_ref()) @@ -54,7 +55,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_jump_target_at(&self, opcode_ip: usize) -> Option { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .jump_targets .get(opcode_ip) .and_then(|target| *target) @@ -62,7 +64,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_jump_target_is_valid_at(&self, opcode_ip: usize) -> bool { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .valid_jump_targets .get(opcode_ip) .copied() @@ -71,7 +74,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_local_index_at(&self, opcode_ip: usize) -> Option { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .local_indices .get(opcode_ip) .and_then(|index| *index) @@ -90,7 +94,7 @@ impl Vm { let Some(initial) = self.local_scalar_value_with_hint(src) else { return Ok(false); }; - let mut cursor = self.ip; + let mut cursor = self.instance.ip; let mut stack = [None; 8]; let mut stack_len = 1usize; stack[0] = Some(initial); @@ -221,7 +225,7 @@ impl Vm { ))?; self.store_local_absolute_with_drop_contract(absolute, dst, value)?; self.record_scalar_superinstruction(); - self.ip = cursor + 2; + self.instance.ip = cursor + 2; return Ok(true); } OpCode::Clt | OpCode::Cgt => { @@ -261,10 +265,10 @@ impl Vm { }, _ => unreachable!(), }; - self.ip = cursor + 6; + self.instance.ip = cursor + 6; if !condition { if self.decoded_jump_target_is_valid_at(jump_opcode_ip) { - self.ip = target; + self.instance.ip = target; } else { self.jump_to(target)?; } diff --git a/src/vm/tests.rs b/src/vm/tests.rs index b7f0e0d1..b90e2750 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -1,7 +1,9 @@ +use super::host::WaitingHostOp; use super::*; use crate::builtins::BuiltinFunction; use crate::bytecode::TypeMap; use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; fn native_cache_test_lock() -> &'static Mutex<()> { @@ -9,18 +11,65 @@ fn native_cache_test_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } +#[test] +fn failed_dynamic_builtin_override_preserves_runtime_owned_pending_binding() { + struct Dummy; + + impl HostFunction for Dummy { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + unreachable!("rejected override must never be installed") + } + } + + let compiled = crate::compile_source("use runtime; runtime::sleep(0);") + .expect("runtime sleep program should compile"); + let mut vm = Vm::new(compiled.program); + vm.ensure_call_bindings() + .expect("default fallback should bind runtime sleep"); + let slot = vm.host.host_function_symbols["runtime::sleep"]; + assert!(vm.host.runtime_owned_pending_host_slots.contains(&slot)); + + vm.bind_builtin_override("runtime::sleep", Box::new(Dummy)) + .expect_err("runtime sleep is a host import, not a builtin override"); + + assert!(vm.host.runtime_owned_pending_host_slots.contains(&slot)); +} + +#[test] +fn failed_static_builtin_override_preserves_runtime_owned_pending_binding() { + fn dummy(_vm: &mut Vm, _args: &[Value]) -> VmResult { + unreachable!("rejected override must never be installed") + } + + let compiled = crate::compile_source("use runtime; runtime::sleep(0);") + .expect("runtime sleep program should compile"); + let mut vm = Vm::new(compiled.program); + vm.ensure_call_bindings() + .expect("default fallback should bind runtime sleep"); + let slot = vm.host.host_function_symbols["runtime::sleep"]; + assert!(vm.host.runtime_owned_pending_host_slots.contains(&slot)); + + vm.bind_builtin_static_override("runtime::sleep", dummy) + .expect_err("runtime sleep is a host import, not a builtin override"); + + assert!(vm.host.runtime_owned_pending_host_slots.contains(&slot)); +} + #[test] fn root_ret_completes_explicit_halt_frame() { let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); - assert_eq!(vm.execution_frames.len(), 1); - assert_eq!(vm.execution_frames[0].continuation, FrameContinuation::Halt); + assert_eq!(vm.instance.execution_frames.len(), 1); + assert_eq!( + vm.instance.execution_frames[0].continuation, + FrameContinuation::Halt + ); assert_eq!(vm.run().expect("root ret should run"), VmStatus::Halted); - assert!(vm.execution_frames.is_empty()); + assert!(vm.instance.execution_frames.is_empty()); assert!(vm.stack().is_empty()); vm.reset_for_reuse(); - assert_eq!(vm.execution_frames.len(), 1); + assert_eq!(vm.instance.execution_frames.len(), 1); assert_eq!(vm.stack(), &[]); } @@ -32,11 +81,356 @@ fn reset_for_reuse_keeps_host_operation_ids_monotonic() { assert_eq!(vm.allocate_host_op_id(), 2); } +#[test] +fn unused_host_operation_ids_do_not_consume_registry_capacity() { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + for _ in 0..128 { + vm.allocate_host_op_id(); + } + assert_eq!(vm.host.runtime_operations.active_count(), 0); +} + +#[test] +fn external_host_operations_join_the_shared_registry_without_id_collisions() { + use crate::builtins::runtime::cancellation::{OperationId, OperationOwner}; + + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let runtime_operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Io, + Some(&vm.run_ctx.cancellation), + None, + None, + ) + .expect("runtime operation should start"); + + let collision = vm + .set_waiting_host_op(runtime_operation.id().raw()) + .expect_err("host operation must not reuse a runtime-owned id"); + assert!(collision.to_string().contains("collides")); + assert!( + vm.host + .runtime_operations + .get(runtime_operation.id()) + .is_ok() + ); + vm.host + .runtime_operations + .complete(runtime_operation.id()) + .expect("runtime operation should complete"); + vm.set_waiting_host_op(runtime_operation.id().raw()) + .expect_err("colliding external operation id must remain retired"); + + vm.set_waiting_host_op(99) + .expect("external host operation should register"); + let external = vm + .host + .runtime_operations + .get(OperationId::from_raw(99).expect("operation id should be valid")) + .expect("external operation should be registered"); + assert_eq!(external.owner(), OperationOwner::HostBridge); +} + +#[test] +fn invalid_host_completion_preserves_the_registered_operation() { + use crate::builtins::runtime::cancellation::{OperationId, OperationOwner}; + + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_waiting_host_op(101) + .expect("external host operation should register"); + + vm.complete_host_op(102, CallReturn::none()) + .expect_err("completion for a different operation should fail"); + let operation_id = OperationId::from_raw(101).expect("operation id should be valid"); + assert_eq!( + vm.host + .runtime_operations + .get(operation_id) + .expect("waiting operation should remain registered") + .owner(), + OperationOwner::HostBridge + ); + assert_eq!(vm.waiting_host_op_id(), Some(101)); +} + +#[test] +fn reset_and_drop_cleanup_real_host_resources_exactly_once() { + use crate::builtins::runtime::cancellation::CancellationReason; + use crate::builtins::runtime::resource::ResourceTypeId; + + let cleanup_count = Arc::new(AtomicUsize::new(0)); + let reasons = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let cleanup_count_for_resource = Arc::clone(&cleanup_count); + let reasons_for_resource = Arc::clone(&reasons); + vm.host + .runtime_resources + .insert_with_cleanup(ResourceTypeId::IO_FILE, (), move |(), reason| { + cleanup_count_for_resource.fetch_add(1, Ordering::SeqCst); + reasons_for_resource + .lock() + .expect("reason lock") + .push(reason); + Ok(()) + }) + .expect("test resource should be inserted"); + + vm.reset_for_reuse(); + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); + assert_eq!( + reasons.lock().expect("reason lock").as_slice(), + &[CancellationReason::VmReset] + ); + + drop(vm); + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); +} + +#[test] +fn drop_cleans_real_host_resources_without_prior_reset() { + use crate::builtins::runtime::cancellation::CancellationReason; + use crate::builtins::runtime::resource::ResourceTypeId; + + let cleanup_count = Arc::new(AtomicUsize::new(0)); + let cleanup_reason = Arc::new(Mutex::new(None)); + { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let cleanup_count_for_resource = Arc::clone(&cleanup_count); + let cleanup_reason_for_resource = Arc::clone(&cleanup_reason); + vm.host + .runtime_resources + .insert_with_cleanup(ResourceTypeId::IO_FILE, (), move |(), reason| { + cleanup_count_for_resource.fetch_add(1, Ordering::SeqCst); + *cleanup_reason_for_resource.lock().expect("reason lock") = Some(reason); + Ok(()) + }) + .expect("test resource should be inserted"); + } + + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); + assert_eq!( + *cleanup_reason.lock().expect("reason lock"), + Some(CancellationReason::VmReset) + ); +} + +#[test] +fn reset_propagates_to_real_host_operation_cleanup() { + use crate::builtins::runtime::cancellation::{ + CancellationReason, OperationEnd, OperationOwner, OperationStatus, + }; + + let cleanup_end = Arc::new(Mutex::new(None)); + let cleanup_end_for_operation = Arc::clone(&cleanup_end); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Io, + Some(&vm.run_ctx.cancellation), + None, + Some(Box::new(move |end| { + *cleanup_end_for_operation.lock().expect("cleanup lock") = Some(end); + Ok(()) + })), + ) + .expect("test operation should start"); + vm.instance.waiting_host_op = Some(WaitingHostOp { + op_id: operation.id().raw(), + }); + + vm.reset_for_reuse(); + assert_eq!( + operation.status(), + OperationStatus::Cancelled(CancellationReason::VmReset) + ); + assert_eq!( + *cleanup_end.lock().expect("cleanup lock"), + Some(OperationEnd::Cancelled(CancellationReason::VmReset)) + ); +} + +#[test] +fn deadline_cancellation_closes_operation_payload_before_registry_removal() { + use crate::builtins::runtime::cancellation::{CancellationReason, OperationOwner}; + use crate::builtins::runtime::resource::ResourceTypeId; + use std::task::{Context, Poll}; + use std::time::{Duration, Instant}; + + let cleanup_reason = Arc::new(Mutex::new(None)); + let cleanup_reason_for_payload = Arc::clone(&cleanup_reason); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Http, + Some(&vm.run_ctx.cancellation), + Some(Instant::now() - Duration::from_millis(1)), + None, + ) + .expect("deadline operation should start"); + let payload = vm + .host + .runtime_resources + .insert_with_cleanup(ResourceTypeId::CALLBACK, (), move |(), reason| { + *cleanup_reason_for_payload.lock().expect("cleanup lock") = Some(reason); + Ok(()) + }) + .expect("payload should be inserted"); + operation.set_payload(payload); + + let waker = futures_util::task::noop_waker(); + let mut context = Context::from_waker(&waker); + let result = + crate::builtins::runtime::poll_builtin_io_op(&mut vm, operation.id().raw(), &mut context); + + assert!(matches!(result, Poll::Ready(Err(_)))); + assert_eq!( + *cleanup_reason.lock().expect("cleanup lock"), + Some(CancellationReason::Deadline) + ); + assert!(vm.host.runtime_operations.get(operation.id()).is_err()); + assert!( + vm.host + .runtime_resources + .get::<()>(payload, ResourceTypeId::CALLBACK) + .is_err() + ); +} + +#[test] +fn worker_observed_deadline_retains_payload_until_vm_consumes_operation() { + use crate::builtins::runtime::cancellation::{CancellationReason, OperationOwner}; + use crate::builtins::runtime::error::{RuntimeError, RuntimeErrorCode}; + use crate::builtins::runtime::resource::ResourceTypeId; + use std::task::{Context, Poll}; + use std::time::{Duration, Instant}; + + let cleanup_reason = Arc::new(Mutex::new(None)); + let cleanup_reason_for_payload = Arc::clone(&cleanup_reason); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Http, + Some(&vm.run_ctx.cancellation), + Some(Instant::now() - Duration::from_millis(1)), + None, + ) + .expect("deadline operation should start"); + let payload = vm + .host + .runtime_resources + .insert_with_cleanup(ResourceTypeId::CALLBACK, (), move |(), reason| { + *cleanup_reason_for_payload.lock().expect("cleanup lock") = Some(reason); + Ok(()) + }) + .expect("payload should be inserted"); + operation.set_payload(payload); + + assert!( + operation + .fail(RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "test::worker", + "worker failure", + )) + .expect("worker terminal transition should succeed") + ); + assert!(vm.host.runtime_operations.get(operation.id()).is_ok()); + + let waker = futures_util::task::noop_waker(); + let mut context = Context::from_waker(&waker); + let result = + crate::builtins::runtime::poll_builtin_io_op(&mut vm, operation.id().raw(), &mut context); + + assert!(matches!(result, Poll::Ready(Err(_)))); + assert_eq!( + *cleanup_reason.lock().expect("cleanup lock"), + Some(CancellationReason::Deadline) + ); + assert!(vm.host.runtime_operations.get(operation.id()).is_err()); + assert!( + vm.host + .runtime_resources + .get::<()>(payload, ResourceTypeId::CALLBACK) + .is_err() + ); +} + +#[cfg(feature = "sqlite")] +#[test] +fn sqlite_reconfiguration_only_closes_sqlite_owned_state() { + use crate::builtins::runtime::cancellation::OperationOwner; + use crate::builtins::runtime::resource::ResourceTypeId; + + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let io_resource = vm + .host + .runtime_resources + .insert(ResourceTypeId::IO_FILE, 11_i64) + .expect("IO resource should be inserted"); + let sqlite_resource = vm + .host + .runtime_resources + .insert(ResourceTypeId::SQLITE_CONNECTION, 22_i64) + .expect("SQLite resource should be inserted"); + let io_operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Io, + Some(&vm.run_ctx.cancellation), + None, + None, + ) + .expect("IO operation should start"); + io_operation.set_resource(io_resource); + let sqlite_operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Sqlite, + Some(&vm.run_ctx.cancellation), + None, + None, + ) + .expect("SQLite operation should start"); + sqlite_operation.set_resource(sqlite_resource); + + vm.configure_sqlite(crate::vm::SqlitePolicy::default()); + + assert!( + vm.host + .runtime_resources + .get::(io_resource, ResourceTypeId::IO_FILE) + .is_ok() + ); + assert!(vm.host.runtime_operations.get(io_operation.id()).is_ok()); + assert!( + vm.host + .runtime_resources + .get::(sqlite_resource, ResourceTypeId::SQLITE_CONNECTION) + .is_err() + ); + assert!( + vm.host + .runtime_operations + .get(sqlite_operation.id()) + .is_err() + ); +} + #[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)); let cell = Arc::new(Mutex::new(Value::Null)); - vm.capture_cells.insert(0, Arc::clone(&cell)); + vm.instance.capture_cells.insert(0, Arc::clone(&cell)); let environment = Arc::new(crate::CallableEnvironment { cells: Mutex::new(vec![cell]), }); @@ -109,7 +503,7 @@ fn callvalue_decodes_its_arity_before_callable_validation() { Vec::new(), vec![OpCode::CallValue as u8, 0, OpCode::Ret as u8], )); - vm.stack.push(Value::Null); + vm.instance.stack.push(Value::Null); assert!(matches!(vm.run(), Err(VmError::InvalidCallable))); assert_eq!(vm.ip(), 2); } @@ -293,7 +687,7 @@ fn aot_executes_move_detach_without_stack_contract_mismatch() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::String(Arc::new("x".to_string()))]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -314,7 +708,7 @@ fn aot_executes_script_callable_frames_without_interpreter_boundary() { ); assert_eq!(vm.stack(), &[Value::Int(42)]); assert!(vm.aot_exec_count() >= 3); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -334,7 +728,7 @@ fn aot_executes_typed_script_callable_parameter_equality_without_interpreter_bou VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Bool(true)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -355,7 +749,7 @@ fn aot_executes_script_callable_bool_return_in_branch_without_interpreter_bounda VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(1)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -377,7 +771,7 @@ fn aot_executes_capturing_closure_without_interpreter_boundary() { ); assert_eq!(vm.stack(), &[Value::Int(42)]); assert!(vm.aot_exec_count() >= 3); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -397,7 +791,7 @@ fn aot_executes_builtin_callable_values_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(3)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -424,7 +818,7 @@ fn aot_callable_call_resumes_after_fuel_yield_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(42)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -445,7 +839,7 @@ fn aot_executes_nested_script_callables_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(42)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -464,7 +858,7 @@ fn aot_recursive_script_callable_reports_depth_limit_without_interpreter_boundar vm.run(), Err(VmError::CallStackOverflow { limit: 1024 }) )); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -493,7 +887,7 @@ fn aot_host_callable_value_waits_and_resumes_without_interpreter_boundary() { vm.run().expect("pending host callable should wait"), VmStatus::Waiting(812) ); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); vm.complete_host_op(812, vec![Value::Int(42)]) .expect("host operation should complete"); assert_eq!( @@ -501,7 +895,7 @@ fn aot_host_callable_value_waits_and_resumes_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(42)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[test] @@ -943,8 +1337,8 @@ fn vm_instances_share_decoded_instruction_metadata_across_program_clones() { assert!( Arc::ptr_eq( - &vm_one.decoded_instruction_data, - &vm_two.decoded_instruction_data + &vm_one.engine.decoded_instruction_data, + &vm_two.engine.decoded_instruction_data ), "program clones should share decoded instruction metadata" ); @@ -969,7 +1363,11 @@ fn borrowed_map_iterator_state_is_released_after_break() { assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); assert!( - vm.map_iterators.iter().flatten().all(Option::is_none), + vm.instance + .map_iterators + .iter() + .flatten() + .all(Option::is_none), "break must release every iterator owned by the exited loop" ); } @@ -991,7 +1389,11 @@ fn borrowed_map_iterator_state_is_released_after_runtime_error() { vm.run().expect_err("program should fail at runtime"); assert!( - vm.map_iterators.iter().flatten().all(Option::is_none), + vm.instance + .map_iterators + .iter() + .flatten() + .all(Option::is_none), "runtime errors must release active map iterators" ); } @@ -1008,7 +1410,7 @@ fn map_iterator_ids_are_isolated_by_call_depth() { }; vm.init_map_iterator(7, outer).expect("outer init"); - vm.call_depth = 1; + vm.instance.call_depth = 1; vm.init_map_iterator(7, inner).expect("inner init"); assert!(vm.advance_map_iterator(7).expect("inner advance")); assert_eq!( @@ -1017,7 +1419,7 @@ fn map_iterator_ids_are_isolated_by_call_depth() { ); vm.close_map_iterator(7).expect("inner close"); - vm.call_depth = 0; + vm.instance.call_depth = 0; assert!(vm.advance_map_iterator(7).expect("outer advance")); assert_eq!( vm.take_map_iterator_key(7).expect("outer key"), @@ -1079,7 +1481,7 @@ fn native_trace_cache_resets_when_program_changes() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_one, - Some(vm_one.program_cache_key), + Some(vm_one.engine.program_cache_key), "cache should be keyed to first program after first run" ); assert_eq!( @@ -1094,7 +1496,7 @@ fn native_trace_cache_resets_when_program_changes() { max_trace_len: 512, }); assert_ne!( - vm_one.program_cache_key, vm_two.program_cache_key, + vm_one.engine.program_cache_key, vm_two.engine.program_cache_key, "test programs should have different cache keys" ); let status_two = vm_two.run().expect("second vm should run"); @@ -1109,7 +1511,7 @@ fn native_trace_cache_resets_when_program_changes() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_two, - Some(vm_two.program_cache_key), + Some(vm_two.engine.program_cache_key), "cache should switch to second program key" ); assert_eq!( @@ -1161,7 +1563,7 @@ fn native_trace_cache_reuses_entries_for_same_program() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_one, - Some(vm_one.program_cache_key), + Some(vm_one.engine.program_cache_key), "cache should be keyed to the first program" ); assert_eq!( @@ -1176,7 +1578,7 @@ fn native_trace_cache_reuses_entries_for_same_program() { max_trace_len: 512, }); assert_eq!( - vm_two.program_cache_key, vm_one.program_cache_key, + vm_two.engine.program_cache_key, vm_one.engine.program_cache_key, "same program should use identical cache key" ); @@ -1192,7 +1594,7 @@ fn native_trace_cache_reuses_entries_for_same_program() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_two, - Some(vm_two.program_cache_key), + Some(vm_two.engine.program_cache_key), "cache key should remain the same for identical program" ); assert_eq!( @@ -1344,7 +1746,7 @@ fn interpreter_superinstructions_use_local_type_hints() { let outcome = step_once(&mut vm).expect("ldloc should fuse scalar sequence"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.locals[0], Value::Int(10)); + assert_eq!(vm.instance.locals[0], Value::Int(10)); let metrics = vm.interpreter_metrics_snapshot(); assert_eq!(metrics.scalar_superinstruction_count, 1); assert!( @@ -1375,7 +1777,8 @@ fn interpreter_ldc_shares_string_constant_backing() { fn interpreter_dup_shares_array_backing() { let program = Program::new(vec![], vec![OpCode::Dup as u8, OpCode::Ret as u8]); let mut vm = Vm::new(program); - vm.stack + vm.instance + .stack .push(Value::array(vec![Value::Int(1), Value::Int(2)])); let outcome = step_once(&mut vm).expect("dup should execute"); @@ -1503,14 +1906,17 @@ fn interpreter_ldloc_preserves_local_slot() { let outcome = step_once(&mut vm).expect("ldloc should execute"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 2); - assert_eq!(vm.locals[0], map_value, "ldloc should leave local intact"); + assert_eq!(vm.instance.ip, 2); + assert_eq!( + vm.instance.locals[0], map_value, + "ldloc should leave local intact" + ); assert_eq!( vm.stack(), &[map_value], "stack should receive copied value" ); - assert_shared_heap_backing(&vm.locals[0], &vm.stack()[0]); + assert_shared_heap_backing(&vm.instance.locals[0], &vm.stack()[0]); assert_eq!(vm.drop_contract_event_count(), 0); } @@ -1539,9 +1945,9 @@ fn interpreter_explicit_move_sequence_clears_local_slot() { let ldloc = step_once(&mut vm).expect("ldloc should execute"); assert!(matches!(ldloc, ExecOutcome::Continue)); - assert_eq!(vm.locals[0], map_value); + assert_eq!(vm.instance.locals[0], map_value); assert_eq!(vm.stack(), std::slice::from_ref(&map_value)); - assert_shared_heap_backing(&vm.locals[0], &vm.stack()[0]); + assert_shared_heap_backing(&vm.instance.locals[0], &vm.stack()[0]); let ldc = step_once(&mut vm).expect("ldc should execute"); assert!(matches!(ldc, ExecOutcome::Continue)); @@ -1549,8 +1955,8 @@ fn interpreter_explicit_move_sequence_clears_local_slot() { let stloc = step_once(&mut vm).expect("stloc should execute"); assert!(matches!(stloc, ExecOutcome::Continue)); - assert_eq!(vm.ip, 9); - assert_eq!(vm.locals[0], Value::Null); + assert_eq!(vm.instance.ip, 9); + assert_eq!(vm.instance.locals[0], Value::Null); assert_eq!(vm.stack(), &[map_value]); } @@ -1579,9 +1985,9 @@ fn interpreter_fuses_ldloc_ldc_add_stloc_without_touching_stack() { let outcome = step_once(&mut vm).expect("fused sequence should execute"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 10, "fusion should consume ldc/add/stloc"); - assert_eq!(vm.locals[0], Value::Int(41)); - assert_eq!(vm.locals[1], Value::Int(42)); + assert_eq!(vm.instance.ip, 10, "fusion should consume ldc/add/stloc"); + assert_eq!(vm.instance.locals[0], Value::Int(41)); + assert_eq!(vm.instance.locals[1], Value::Int(42)); assert!( vm.stack().is_empty(), "fusion should avoid transient stack traffic" @@ -1621,7 +2027,10 @@ fn interpreter_fuses_ldloc_ldc_compare_brfalse() { let outcome = step_once(&mut vm).expect("fused compare should execute"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 15, "fusion should jump directly to branch target"); + assert_eq!( + vm.instance.ip, 15, + "fusion should jump directly to branch target" + ); assert!( vm.stack().is_empty(), "fusion should avoid bool stack traffic" @@ -1664,9 +2073,9 @@ fn interpreter_fuses_generic_scalar_update_chain() { let outcome = step_once(&mut vm).expect("generic chain should fuse"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 19); - assert_eq!(vm.locals[0], Value::Int(29)); - assert_eq!(vm.locals[1], Value::Int(4)); + assert_eq!(vm.instance.ip, 19); + assert_eq!(vm.instance.locals[0], Value::Int(29)); + assert_eq!(vm.instance.locals[1], Value::Int(4)); assert!(vm.stack().is_empty()); } @@ -1708,13 +2117,13 @@ fn interpreter_fuses_float_scalar_sequences() { let first = step_once(&mut vm).expect("float update should fuse"); assert!(matches!(first, ExecOutcome::Continue)); - assert_eq!(vm.ip, 10); - assert_eq!(vm.locals[0], Value::Float(2.5)); + assert_eq!(vm.instance.ip, 10); + assert_eq!(vm.instance.locals[0], Value::Float(2.5)); assert!(vm.stack().is_empty()); let second = step_once(&mut vm).expect("float compare should fuse"); assert!(matches!(second, ExecOutcome::Continue)); - assert_eq!(vm.ip, 23); + assert_eq!(vm.instance.ip, 23); assert!(vm.stack().is_empty()); } @@ -1747,9 +2156,12 @@ fn interpreter_does_not_fuse_ldloc_sequences_when_fuel_is_enabled() { .execute_interpreter_instruction(opcode, false) .expect("ldloc should execute without fusion"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 2, "ldloc should advance only past its operand"); + assert_eq!( + vm.instance.ip, 2, + "ldloc should advance only past its operand" + ); assert_eq!(vm.stack(), &[Value::Int(41)]); - assert_eq!(vm.locals[0], Value::Int(41)); + assert_eq!(vm.instance.locals[0], Value::Int(41)); } #[test] @@ -1776,7 +2188,7 @@ fn interpreter_copy_like_ldloc_dup_stloc_shares_map_backing_with_fuel() { let _ = step_once(&mut vm).expect("stloc should execute"); assert_eq!(vm.stack().len(), 1); - assert_shared_heap_backing(&vm.locals[0], &vm.stack()[0]); + assert_shared_heap_backing(&vm.instance.locals[0], &vm.stack()[0]); } #[test] @@ -1787,11 +2199,14 @@ fn interpreter_fuses_call_ret_without_fuel() { vec![OpCode::Call as u8, call_lo, call_hi, 1, OpCode::Ret as u8], ); let mut vm = Vm::new(program); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let outcome = step_once(&mut vm).expect("call should execute"); assert!(matches!(outcome, ExecOutcome::Halted)); - assert_eq!(vm.ip, 5, "tail-call fusion should consume trailing ret"); + assert_eq!( + vm.instance.ip, 5, + "tail-call fusion should consume trailing ret" + ); assert_eq!(vm.stack(), &[Value::Int(4)]); } @@ -1804,12 +2219,15 @@ fn interpreter_fuses_call_ret_when_fuel_enabled_if_tail_tick_available() { ); let mut vm = Vm::new(program); vm.set_fuel(1); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); // `step_once` bypasses the outer run-loop pre-tick, so this fuel only covers fused `ret`. let call = step_once(&mut vm).expect("call should execute"); assert!(matches!(call, ExecOutcome::Halted)); - assert_eq!(vm.ip, 5, "tail-call fusion should consume trailing ret"); + assert_eq!( + vm.instance.ip, 5, + "tail-call fusion should consume trailing ret" + ); assert_eq!(vm.stack(), &[Value::Int(4)]); assert_eq!(vm.get_fuel(), Some(0)); } @@ -1823,7 +2241,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_tail_tick_exhausted() { ); let mut vm = Vm::new(program); vm.set_fuel(0); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let err = match step_once(&mut vm) { Ok(_) => panic!("tail tick should fail with out-of-fuel"), @@ -1831,7 +2249,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_tail_tick_exhausted() { }; assert!(matches!(err, VmError::OutOfFuel { .. })); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "ret must remain pending when tail tick cannot be charged" ); assert_eq!(vm.stack(), &[Value::Int(4)]); @@ -1847,7 +2265,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_epoch_deadline_is_reached() { let mut vm = Vm::new(program); vm.set_epoch_deadline(0) .expect("setting epoch deadline should succeed"); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let err = match step_once(&mut vm) { Ok(_) => panic!("tail tick should fail with epoch deadline reached"), @@ -1855,7 +2273,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_epoch_deadline_is_reached() { }; assert!(matches!(err, VmError::EpochDeadlineReached { .. })); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "ret must remain pending when the epoch check trips during fused tail execution" ); assert_eq!(vm.stack(), &[Value::Int(4)]); @@ -1870,11 +2288,11 @@ fn run_consumes_two_ticks_for_call_ret_when_fuel_enabled() { ); let mut vm = Vm::new(program); vm.set_fuel(2); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let status = vm.run().expect("run should complete"); assert_eq!(status, VmStatus::Halted); - assert_eq!(vm.ip, 5); + assert_eq!(vm.instance.ip, 5); assert_eq!(vm.stack(), &[Value::Int(4)]); assert_eq!( vm.get_fuel(), @@ -1892,12 +2310,12 @@ fn run_yields_before_ret_in_call_ret_sequence_when_out_of_fuel() { ); let mut vm = Vm::new(program); vm.set_fuel(1); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let status = vm.run().expect("first run should yield"); assert_eq!(status, VmStatus::Yielded); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "fuel exhaustion should happen before trailing ret" ); assert_eq!(vm.stack(), &[Value::Int(4)]); @@ -1906,7 +2324,7 @@ fn run_yields_before_ret_in_call_ret_sequence_when_out_of_fuel() { vm.add_fuel(1).expect("recharging fuel should succeed"); let resumed = vm.resume().expect("resume should execute trailing ret"); assert_eq!(resumed, VmStatus::Halted); - assert_eq!(vm.ip, 5); + assert_eq!(vm.instance.ip, 5); assert_eq!(vm.stack(), &[Value::Int(4)]); } @@ -1923,12 +2341,12 @@ fn run_yields_before_ret_in_call_ret_sequence_when_epoch_deadline_is_reached() { vm.set_epoch_deadline(1) .expect("setting epoch deadline should succeed"); assert_eq!(vm.increment_epoch(), 1); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let status = vm.run().expect("first run should yield"); assert_eq!(status, VmStatus::Yielded); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "epoch interruption should happen before trailing ret" ); assert_eq!(vm.last_yield_reason(), Some(VmYieldReason::Epoch)); @@ -1938,7 +2356,7 @@ fn run_yields_before_ret_in_call_ret_sequence_when_epoch_deadline_is_reached() { .resume() .expect("resume should auto re-arm the epoch deadline and execute trailing ret"); assert_eq!(resumed, VmStatus::Halted); - assert_eq!(vm.ip, 5); + assert_eq!(vm.instance.ip, 5); assert_eq!(vm.stack(), &[Value::Int(4)]); } @@ -1950,7 +2368,7 @@ fn call_ret_fusion_pattern_requires_immediate_ret() { vec![OpCode::Call as u8, call_lo, call_hi, 1, OpCode::Ret as u8], ); let mut vm_with_ret = Vm::new(with_ret); - vm_with_ret.ip = 4; + vm_with_ret.instance.ip = 4; assert!(vm_with_ret.can_fuse_call_ret_pattern()); let wrong_next = Program::new( @@ -1958,11 +2376,11 @@ fn call_ret_fusion_pattern_requires_immediate_ret() { vec![OpCode::Call as u8, call_lo, call_hi, 1, OpCode::Nop as u8], ); let mut vm_wrong_next = Vm::new(wrong_next); - vm_wrong_next.ip = 4; + vm_wrong_next.instance.ip = 4; assert!(!vm_wrong_next.can_fuse_call_ret_pattern()); let no_next = Program::new(vec![], vec![OpCode::Call as u8, call_lo, call_hi, 1]); let mut vm_no_next = Vm::new(no_next); - vm_no_next.ip = 4; + vm_no_next.instance.ip = 4; assert!(!vm_no_next.can_fuse_call_ret_pattern()); } diff --git a/tests/builtins/io_builtin_edge_tests.rs b/tests/builtins/io_builtin_edge_tests.rs index 0e58dc0c..9348ed44 100644 --- a/tests/builtins/io_builtin_edge_tests.rs +++ b/tests/builtins/io_builtin_edge_tests.rs @@ -1,5 +1,10 @@ use vm::{Value, Vm, VmError, VmStatus, compile_source}; +#[cfg(unix)] +use std::path::PathBuf; +#[cfg(unix)] +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + fn run_source(source: &str) -> Result, VmError> { let wrapped = format!("use io;\n{source}"); let compiled = compile_source(&wrapped).expect("source should compile"); @@ -28,6 +33,162 @@ fn run_source_host_error(source: &str) -> String { } } +#[cfg(unix)] +fn unique_temp_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should follow the Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("pd-vm-{label}-{}-{nonce}", std::process::id())) +} + +#[cfg(unix)] +fn process_exists(process_id: i32) -> bool { + // SAFETY: signal zero performs existence/permission checking without sending a signal. + let result = unsafe { libc::kill(process_id, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[test] +fn io_callback_resource_is_registered_before_worker_spawn() { + let source = include_str!("../../src/builtins/runtime/io.rs"); + let schedule = source + .split_once("fn schedule_io_task(") + .expect("schedule_io_task should exist") + .1 + .split_once("fn runtime_host_error(") + .expect("schedule_io_task should precede runtime_host_error") + .0; + let callback_registration = schedule + .find(".insert(ResourceTypeId::CALLBACK, receiver)") + .expect("schedule_io_task should register its callback receiver"); + let worker_spawn = schedule + .find(".spawn(move ||") + .expect("schedule_io_task should spawn its worker"); + + assert!( + callback_registration < worker_spawn, + "callback receiver must be registered before the worker can run" + ); +} + +#[test] +fn popen_teardown_does_not_invoke_external_kill_programs() { + let source = include_str!("../../src/builtins/runtime/io.rs"); + assert!( + !source.contains("Command::new(\"kill\")"), + "Unix popen teardown must use the platform process API" + ); + assert!( + !source.contains("Command::new(\"taskkill\")"), + "Windows popen teardown must use the platform process API" + ); +} + +#[cfg(unix)] +#[test] +fn reset_terminates_popen_descendants() { + let child_pid_path = unique_temp_path("popen-descendant-pid"); + let command = format!( + "sleep 3600 & child=$!; echo $child > {}; wait", + child_pid_path.display() + ); + let compiled = compile_source(&format!( + r#" + use io; + let handle = io::popen("{command}", "r"); + io::read_all(handle); + "# + )) + .expect("descendant popen source should compile"); + let mut vm = Vm::new(compiled.program); + + let first = vm.run().expect("popen should start"); + assert!(matches!(first, VmStatus::Waiting(_))); + vm.wait_for_host_op_blocking() + .expect("popen should complete"); + let second = vm.resume().expect("read_all should start"); + assert!(matches!(second, VmStatus::Waiting(_))); + + let pid_deadline = Instant::now() + Duration::from_secs(2); + while !child_pid_path.exists() && Instant::now() < pid_deadline { + std::thread::sleep(Duration::from_millis(5)); + } + let child_pid = std::fs::read_to_string(&child_pid_path) + .expect("popen command should publish its descendant pid") + .trim() + .parse::() + .expect("descendant pid should be numeric"); + assert!(process_exists(child_pid), "descendant should be running"); + + vm.reset_for_reuse(); + + let exit_deadline = Instant::now() + Duration::from_secs(2); + while process_exists(child_pid) && Instant::now() < exit_deadline { + std::thread::sleep(Duration::from_millis(5)); + } + let _ = std::fs::remove_file(&child_pid_path); + assert!( + !process_exists(child_pid), + "popen descendant {child_pid} survived VM reset" + ); +} + +#[cfg(unix)] +#[test] +fn reset_interrupts_a_blocked_popen_read_within_a_bounded_time() { + let compiled = compile_source( + r#" + use io; + let handle = io::popen("sleep 3600", "r"); + io::read_all(handle); + "#, + ) + .expect("blocking popen source should compile"); + let mut vm = Vm::new(compiled.program); + + let first = vm.run().expect("popen should start"); + assert!(matches!(first, VmStatus::Waiting(_))); + vm.wait_for_host_op_blocking() + .expect("popen should complete"); + let second = vm.resume().expect("read_all should start"); + assert!(matches!(second, VmStatus::Waiting(_))); + std::thread::sleep(Duration::from_millis(25)); + + let started = Instant::now(); + vm.reset_for_reuse(); + assert!( + started.elapsed() < Duration::from_secs(2), + "reset exceeded bounded I/O teardown window: {:?}", + started.elapsed() + ); +} + +#[cfg(unix)] +#[test] +fn reset_reaps_a_popen_child_before_completion_is_polled() { + let compiled = compile_source( + r#" + use io; + io::popen("sleep 3599", "r"); + "#, + ) + .expect("popen source should compile"); + let mut vm = Vm::new(compiled.program); + + let status = vm.run().expect("popen should enter waiting state"); + assert!(matches!(status, VmStatus::Waiting(_))); + std::thread::sleep(Duration::from_millis(100)); + + let started = Instant::now(); + vm.reset_for_reuse(); + assert!( + started.elapsed() < Duration::from_secs(2), + "reset exceeded queued-completion teardown window: {:?}", + started.elapsed() + ); +} + #[test] fn io_open_rejects_unsupported_mode() { let err = run_source_host_error( @@ -119,3 +280,33 @@ fn io_flush_on_read_handle_is_a_noop_true() { .expect("program should execute"); assert_eq!(stack.last(), Some(&Value::Bool(true))); } + +#[test] +fn io_close_rejects_a_stale_resource_handle() { + let err = run_source_host_error( + r#" + let handle = io::open("Cargo.toml", "r"); + io::close(handle); + io::close(handle); + "#, + ); + assert!( + err.contains("resource_already_closed"), + "unexpected error message: {err}" + ); +} + +#[test] +fn io_handles_cannot_cross_vm_resource_arenas() { + let stack = run_source(r#"io::open("Cargo.toml", "r");"#) + .expect("first VM should open a file resource"); + let Value::Int(handle) = stack.last().expect("open should return a handle") else { + panic!("open should return an integer resource handle"); + }; + + let err = run_source_host_error(&format!("io::close({handle});")); + assert!( + err.contains("resource_handle_wrong_table"), + "unexpected error message: {err}" + ); +} diff --git a/tests/host_binding_generation_tests.rs b/tests/host_binding_generation_tests.rs index e465a9dc..3e44c1af 100644 --- a/tests/host_binding_generation_tests.rs +++ b/tests/host_binding_generation_tests.rs @@ -201,6 +201,44 @@ fn runtime_sleep_default_bindings_remain_inside_jit_loop_traces() { assert_runtime_sleep_loop_uses_native_host_call(true); } +#[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, + }); + HostFunctionRegistry::restricted() + .bind_vm_cached(&mut vm) + .expect("restricted registry should bind program imports"); + + assert!(matches!(vm.run(), Err(vm::VmError::UnboundImport(_)))); + assert_eq!(vm.jit_native_exec_count(), 0); + } +} + #[test] fn runtime_exit_still_halts_for_direct_and_cached_default_bindings() { for bind_cached_registry in [false, true] { diff --git a/tests/runtime_context_tests.rs b/tests/runtime_context_tests.rs new file mode 100644 index 00000000..31dfb06b --- /dev/null +++ b/tests/runtime_context_tests.rs @@ -0,0 +1,401 @@ +mod vm { + pub use ::vm::{Value, VmError, VmResult}; +} + +#[allow(dead_code)] +#[path = "../src/builtins/runtime/cancellation.rs"] +mod cancellation; +#[allow(dead_code)] +#[path = "../src/builtins/runtime/context.rs"] +mod context; +#[allow(dead_code)] +#[path = "../src/builtins/runtime/error.rs"] +mod error; +#[allow(dead_code)] +#[path = "../src/builtins/runtime/event.rs"] +mod event; +#[allow(dead_code)] +#[path = "../src/builtins/runtime/resource.rs"] +mod resource; + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier, Mutex}; +use std::time::{Duration, Instant}; + +use cancellation::{CancellationReason, OperationRegistry, OperationStatus}; +use context::{RuntimeContext, RuntimeContextConfig}; +use error::RuntimeErrorCode; +use event::{EventLimits, EventPayload}; +use resource::{CloseStatus, ResourceArena, ResourceHandle, ResourceTypeId}; +use vm::Value; + +#[test] +fn runtime_input_is_run_scoped_and_missing_input_is_typed() { + let mut context = RuntimeContext::default(); + let missing = context.input().expect_err("unset input should be rejected"); + assert_eq!(missing.code(), RuntimeErrorCode::InputUnavailable); + + let input = Value::map(vec![(Value::string("kind"), Value::string("message"))]); + context + .set_input(input.clone()) + .expect("input should be accepted"); + assert_eq!(context.input().expect("input should be available"), input); +} + +#[test] +fn runtime_emit_validates_payload_before_calling_the_sink() { + let mut context = RuntimeContext::with_config(RuntimeContextConfig::new( + EventLimits::new(8, 4).expect("test limits should be valid"), + )) + .expect("context should be constructible"); + let seen = Arc::new(Mutex::new(Vec::::new())); + let seen_by_sink = Arc::clone(&seen); + context + .set_event_sink(move |payload: EventPayload| { + seen_by_sink + .lock() + .expect("event sink lock should not be poisoned") + .push(payload.into_value()); + Ok(()) + }) + .expect("event sink should be installed"); + + context + .emit(Value::string("ok")) + .expect("bounded event should reach the sink"); + assert_eq!(seen.lock().expect("event sink lock").len(), 1); + + let too_large = context + .emit(Value::string("payload-too-large")) + .expect_err("oversized event should be rejected"); + assert_eq!(too_large.code(), RuntimeErrorCode::EventPayloadTooLarge); + assert_eq!(seen.lock().expect("event sink lock").len(), 1); +} + +#[test] +fn runtime_emit_reports_missing_sink_without_dropping_the_value_contract() { + let mut context = RuntimeContext::default(); + let error = context + .emit(Value::Bool(true)) + .expect_err("emit without a sink should fail"); + assert_eq!(error.code(), RuntimeErrorCode::EventSinkUnavailable); +} + +#[test] +fn resource_handles_are_opaque_bounded_typed_and_cleanup_is_idempotent() { + let cleanup_count = Arc::new(AtomicUsize::new(0)); + let count_for_cleanup = Arc::clone(&cleanup_count); + let mut arena = ResourceArena::with_limit(1).expect("resource limit should be valid"); + let handle = arena + .insert_with_cleanup(ResourceTypeId::IO_FILE, 7_u32, move |resource, reason| { + assert_eq!(resource, 7); + assert_eq!(reason, CancellationReason::ResourceClosed); + count_for_cleanup.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + .expect("first resource should be allocated"); + + assert_eq!( + arena + .get::(handle, ResourceTypeId::IO_FILE) + .expect("handle should resolve"), + &7 + ); + assert_eq!( + ResourceHandle::from_value(&handle.as_value()).expect("VM value should decode"), + handle + ); + let Value::Int(encoded) = handle.as_value() else { + unreachable!("resource handle should encode as an integer"); + }; + let forged_generation = ResourceHandle::from_value(&Value::Int(encoded + (1 << 8))) + .expect("the altered token remains structurally valid"); + let forged = arena + .get::(forged_generation, ResourceTypeId::IO_FILE) + .expect_err("an altered generation must not resolve"); + assert_eq!(forged.code(), RuntimeErrorCode::ResourceStale); + let wrong_type = arena + .get::(handle, ResourceTypeId::SQLITE_CONNECTION) + .expect_err("wrong resource type should be rejected"); + assert_eq!(wrong_type.code(), RuntimeErrorCode::ResourceTypeMismatch); + let limit_error = arena + .insert(ResourceTypeId::IO_FILE, 8_u32) + .expect_err("the bounded arena should reject a second resource"); + assert_eq!(limit_error.code(), RuntimeErrorCode::ResourceLimitExceeded); + + assert_eq!( + arena + .close(handle, CancellationReason::ResourceClosed) + .expect("close should succeed"), + CloseStatus::Closed + ); + assert_eq!( + arena + .close(handle, CancellationReason::ResourceClosed) + .expect("repeated close should be harmless"), + CloseStatus::AlreadyClosed + ); + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); + + let replacement = arena + .insert(ResourceTypeId::IO_FILE, 9_u32) + .expect("capacity should be reusable after close"); + assert_ne!( + replacement, handle, + "reusing a slot must change its generation" + ); + let closed = arena + .get::(handle, ResourceTypeId::IO_FILE) + .expect_err("the prior generation must not resolve after slot reuse"); + assert_eq!(closed.code(), RuntimeErrorCode::ResourceStale); + assert_eq!( + arena + .get::(replacement, ResourceTypeId::IO_FILE) + .expect("the replacement generation should resolve"), + &9 + ); +} + +#[test] +fn resource_handles_cannot_cross_resource_arenas() { + let mut first = ResourceArena::with_limit(1).expect("resource limit should be valid"); + let second = ResourceArena::with_limit(1).expect("resource limit should be valid"); + let handle = first + .insert(ResourceTypeId::IO_FILE, 1_u32) + .expect("resource should be allocated"); + + let error = second + .get::(handle, ResourceTypeId::IO_FILE) + .expect_err("a handle from another arena must be rejected"); + assert_eq!(error.code(), RuntimeErrorCode::ResourceHandleWrongTable); +} + +#[test] +fn cancellation_transitions_once_and_runs_cleanup_once() { + let cleanup_count = Arc::new(AtomicUsize::new(0)); + let count_for_cleanup = Arc::clone(&cleanup_count); + let mut registry = OperationRegistry::with_limit(2).expect("operation limit should be valid"); + let operation = registry + .start_owned( + cancellation::OperationOwner::Io, + None, + None, + Some(Box::new(move |end| { + assert_eq!( + end, + cancellation::OperationEnd::Cancelled(CancellationReason::Requested) + ); + count_for_cleanup.fetch_add(1, Ordering::SeqCst); + Ok(()) + })), + ) + .expect("operation should start"); + let token = operation.token(); + + assert_eq!(operation.status(), OperationStatus::Pending); + assert!( + operation + .cancel(CancellationReason::Requested) + .expect("cancel should succeed") + ); + assert!( + !operation + .cancel(CancellationReason::Requested) + .expect("cancel is idempotent") + ); + assert_eq!( + operation.status(), + OperationStatus::Cancelled(CancellationReason::Requested) + ); + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); + let cancelled = token + .check() + .expect_err("the cancellation token should stop the operation"); + assert_eq!(cancelled.code(), RuntimeErrorCode::OperationCancelled); + assert!( + !operation + .complete() + .expect("terminal operation should remain terminal") + ); +} + +#[test] +fn cancellation_after_completion_does_not_reopen_or_relabel_operation() { + let mut registry = OperationRegistry::with_limit(2).expect("operation limit should be valid"); + let operation = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect("operation should start"); + assert!(operation.complete().expect("operation should complete")); + assert!( + !operation + .cancel(CancellationReason::Requested) + .expect("cancel is idempotent") + ); + assert_eq!(operation.status(), OperationStatus::Completed); + assert!(!operation.token().is_cancelled()); +} + +#[test] +fn operation_registry_bounds_active_operations_and_releases_cancelled_state() { + let mut registry = OperationRegistry::with_limit(1).expect("operation limit should be valid"); + let operation = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect("first operation should start"); + let limit_error = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect_err("active operation limit should be enforced"); + assert_eq!(limit_error.code(), RuntimeErrorCode::OperationLimitExceeded); + + assert!( + registry + .cancel(operation.id(), CancellationReason::VmReset) + .expect("registry cancellation should succeed") + ); + assert_eq!(registry.active_count(), 0); + assert!(matches!( + operation.status(), + OperationStatus::Cancelled(CancellationReason::VmReset) + )); +} + +#[test] +fn registry_retains_terminal_result_until_it_is_consumed() { + let mut registry = OperationRegistry::with_limit(1).expect("operation limit should be valid"); + let operation = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect("operation should start"); + assert!(operation.complete().expect("completion should succeed")); + + let limit_error = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect_err("unconsumed terminal result should retain its registry slot"); + assert_eq!(limit_error.code(), RuntimeErrorCode::OperationLimitExceeded); + assert!(registry.get(operation.id()).is_ok()); + + assert!( + !registry + .complete(operation.id()) + .expect("consuming an already completed operation should succeed") + ); + assert!(registry.get(operation.id()).is_err()); + registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect("consuming the terminal result should release capacity"); +} + +#[test] +fn concurrent_completion_and_cancellation_choose_one_terminal_state() { + let cleanup_count = Arc::new(AtomicUsize::new(0)); + let cleanup_for_operation = Arc::clone(&cleanup_count); + let mut registry = OperationRegistry::with_limit(2).expect("operation limit should be valid"); + let operation = registry + .start_owned( + cancellation::OperationOwner::Io, + None, + None, + Some(Box::new(move |_| { + cleanup_for_operation.fetch_add(1, Ordering::SeqCst); + Ok(()) + })), + ) + .expect("operation should start"); + let barrier = Arc::new(Barrier::new(3)); + + let complete_operation = operation.clone(); + let complete_barrier = Arc::clone(&barrier); + let complete = std::thread::spawn(move || { + complete_barrier.wait(); + complete_operation + .complete() + .expect("completion should run") + }); + + let cancel_operation = operation.clone(); + let cancel_barrier = Arc::clone(&barrier); + let cancel = std::thread::spawn(move || { + cancel_barrier.wait(); + cancel_operation + .cancel(CancellationReason::Requested) + .expect("cancellation should run") + }); + + barrier.wait(); + let terminal_wins = usize::from(complete.join().expect("completion thread")) + + usize::from(cancel.join().expect("cancellation thread")); + assert_eq!(terminal_wins, 1); + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); + match operation.status() { + OperationStatus::Completed => assert_eq!(operation.token().reason(), None), + OperationStatus::Cancelled(reason) => { + assert_eq!(reason, CancellationReason::Requested); + assert_eq!(operation.token().reason(), Some(reason)); + } + status => panic!("unexpected terminal state: {status:?}"), + } +} + +#[test] +fn completed_child_ignores_later_parent_cancellation() { + let mut registry = OperationRegistry::with_limit(4).expect("operation limit should be valid"); + let parent = registry + .start_owned(cancellation::OperationOwner::Http, None, None, None) + .expect("parent should start"); + let child = registry + .start_owned( + cancellation::OperationOwner::Io, + Some(&parent.token()), + None, + None, + ) + .expect("child should start"); + + assert!(child.complete().expect("child should complete")); + assert!( + parent + .cancel(CancellationReason::Requested) + .expect("parent should cancel") + ); + assert_eq!(child.status(), OperationStatus::Completed); + assert_eq!(child.token().reason(), None); +} + +#[test] +fn expired_deadline_is_the_status_token_and_cleanup_reason() { + let cleanup_end = Arc::new(Mutex::new(None)); + let cleanup_end_for_operation = Arc::clone(&cleanup_end); + let mut registry = OperationRegistry::with_limit(4).expect("operation limit should be valid"); + let parent = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect("parent should start"); + let operation = registry + .start_owned( + cancellation::OperationOwner::Io, + Some(&parent.token()), + Some(Instant::now() - Duration::from_millis(1)), + Some(Box::new(move |end| { + *cleanup_end_for_operation.lock().expect("cleanup lock") = Some(end); + Ok(()) + })), + ) + .expect("deadline child should start"); + + assert!( + operation + .cancel(CancellationReason::Requested) + .expect("deadline cancellation should run") + ); + assert_eq!( + operation.token().reason(), + Some(CancellationReason::Deadline) + ); + assert_eq!( + operation.status(), + OperationStatus::Cancelled(CancellationReason::Deadline) + ); + assert_eq!( + *cleanup_end.lock().expect("cleanup lock"), + Some(cancellation::OperationEnd::Cancelled( + CancellationReason::Deadline + )) + ); +} diff --git a/tests/runtime_host_tests.rs b/tests/runtime_host_tests.rs new file mode 100644 index 00000000..af5d5785 --- /dev/null +++ b/tests/runtime_host_tests.rs @@ -0,0 +1,87 @@ +#![cfg(feature = "runtime")] + +use std::sync::{Arc, Mutex}; + +use vm::{ + EventPayload, EventSink, HostFunctionRegistry, RuntimeResult, Value, Vm, VmStatus, + compile_source, +}; + +struct RecordingEventSink(Arc>>); + +impl EventSink for RecordingEventSink { + fn emit(&mut self, payload: EventPayload) -> RuntimeResult<()> { + self.0 + .lock() + .expect("event capture lock should not be poisoned") + .push(payload.into_value()); + Ok(()) + } +} + +#[test] +fn runtime_input_host_reads_embedding_run_value() { + let program = compile_source( + r#" + use runtime; + runtime::input(); + "#, + ) + .expect("runtime input source should compile") + .program; + let mut vm = Vm::new(program); + vm.set_runtime_input(Value::string("run-input")) + .expect("runtime input should be configurable"); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default runtime host registry should bind"); + + assert_eq!( + vm.run().expect("runtime input should execute"), + VmStatus::Halted + ); + assert_eq!(vm.stack().last(), Some(&Value::string("run-input"))); +} + +#[test] +fn runtime_input_host_reports_missing_embedding_value() { + let program = compile_source( + r#" + use runtime; + runtime::input(); + "#, + ) + .expect("runtime input source should compile") + .program; + let mut vm = Vm::new(program); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default runtime host registry should bind"); + + let error = vm.run().expect_err("missing runtime input should fail"); + assert!(error.to_string().contains("input_unavailable")); +} + +#[test] +fn public_runtime_event_contract_is_implementable_and_configurable() { + let program = compile_source("0;") + .expect("minimal runtime host program should compile") + .program; + let events = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(program); + vm.set_runtime_event_sink(RecordingEventSink(Arc::clone(&events))) + .expect("public EventSink implementation should be configurable"); + vm.clear_runtime_event_sink(); +} + +#[cfg(feature = "sqlite")] +#[test] +fn public_sqlite_policy_configures_the_production_vm() { + let program = compile_source("0;") + .expect("minimal SQLite host program should compile") + .program; + let mut vm = Vm::new(program); + vm.configure_sqlite(vm::SqlitePolicy::default()); + let _limits = vm::SqliteLimits::default(); + vm.clear_sqlite_configuration(); +} diff --git a/tests/vm/http_host_tests.rs b/tests/vm/http_host_tests.rs index 43e070ac..2e30abdf 100644 --- a/tests/vm/http_host_tests.rs +++ b/tests/vm/http_host_tests.rs @@ -2,7 +2,10 @@ use std::io::{Read, Write}; use std::net::TcpListener; use std::thread; -use vm::{HostFunctionRegistry, HttpConfig, Program, Value, Vm, VmStatus, compile_source}; +use vm::{ + CallOutcome, CallReturn, HostFunctionRegistry, HttpConfig, Program, Value, Vm, VmStatus, + compile_source, +}; fn build_request_program(url: String) -> Program { compile_source(&format!( @@ -115,3 +118,193 @@ fn http_host_rejects_targets_until_an_explicit_policy_allows_them() { "unexpected error: {error}" ); } + +#[test] +fn empty_registry_keeps_language_builtins_but_rejects_http_capability() { + let mut language_vm = Vm::new( + vm::compile_source("assert(true);") + .expect("language builtin program should compile") + .program, + ); + HostFunctionRegistry::empty() + .bind_vm_cached(&mut language_vm) + .expect("empty registry should bind a program without host imports"); + assert_eq!( + language_vm.run().expect("language builtin should run"), + VmStatus::Halted + ); + + let mut http_vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + HostFunctionRegistry::restricted() + .bind_vm_cached(&mut http_vm) + .expect("empty registry should prepare the program"); + let error = http_vm + .run() + .expect_err("unapproved HTTP capability must be rejected"); + assert!(matches!(error, vm::VmError::UnboundImport(name) if name == "http::client::request")); +} + +#[test] +fn restricted_registry_requires_explicit_namespaced_builtin_capability() { + let compiled = compile_source( + r#"use io; +io::open("/tmp/rustscript-capability-test", "r");"#, + ) + .expect("namespaced host builtin should compile"); + let mut vm = Vm::new(compiled.program); + HostFunctionRegistry::restricted() + .bind_vm_cached(&mut vm) + .expect("restricted registry should bind namespaced host builtin"); + let error = vm + .run() + .expect_err("restricted registry should reject ungranted namespaced builtin"); + assert!(matches!(error, vm::VmError::UnboundImport(name) if name == "io_open")); +} + +#[test] +fn capability_binding_plan_cannot_cross_registry_profiles() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let unrestricted = HostFunctionRegistry::new(); + let plan = unrestricted + .prepare_plan(&program.imports) + .expect("unrestricted registry should prepare HTTP plan"); + let mut vm = Vm::new(program); + let error = HostFunctionRegistry::restricted() + .bind_vm_with_plan(&mut vm, &plan) + .expect_err("capability plan must not cross registry profiles"); + assert!(error.to_string().contains("different capability profile")); +} + +#[test] +fn capability_binding_plan_cannot_outlive_registry_mutation() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let mut registry = HostFunctionRegistry::new(); + let plan = registry + .prepare_plan(&program.imports) + .expect("registry should prepare HTTP plan"); + registry + .allow_builtin("http::client::request") + .expect("HTTP capability should be a known host callable"); + let mut vm = Vm::new(program); + let error = registry + .bind_vm_with_plan(&mut vm, &plan) + .expect_err("stale capability plan must not bind"); + assert!(error.to_string().contains("different capability state")); +} + +#[test] +fn capability_binding_plan_detects_divergent_registry_clone_mutations() { + let unchanged_program = build_request_program("http://127.0.0.1:1/".to_string()); + let unchanged_registry = HostFunctionRegistry::restricted(); + let unchanged_plan = unchanged_registry + .prepare_plan(&unchanged_program.imports) + .expect("restricted registry should prepare HTTP plan"); + let unchanged_clone = unchanged_registry.clone(); + let mut unchanged_vm = Vm::new(unchanged_program); + unchanged_clone + .bind_vm_with_plan(&mut unchanged_vm, &unchanged_plan) + .expect("an unchanged registry clone should reuse the plan"); + + let branch_program = build_request_program("http://127.0.0.1:1/".to_string()); + let branch_registry = HostFunctionRegistry::restricted(); + let mut first_mutation = branch_registry.clone(); + let mut second_mutation = branch_registry; + first_mutation + .allow_builtin("http::client::request") + .expect("HTTP capability should be known"); + second_mutation + .allow_builtin("io::open") + .expect("io capability should be known"); + let plan = first_mutation + .prepare_plan(&branch_program.imports) + .expect("first capability branch should prepare HTTP plan"); + let mut mutated_vm = Vm::new(branch_program); + let error = second_mutation + .bind_vm_with_plan(&mut mutated_vm, &plan) + .expect_err("divergent capability branches must reject each other's plan"); + assert!(error.to_string().contains("different capability state")); +} + +#[test] +fn registry_state_rejects_structural_sibling_mutations() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let registry = HostFunctionRegistry::new(); + let mut source = registry.clone(); + let destination = registry; + source.register_static_args("test::structural", 0, |_args| { + Ok(CallOutcome::Return(CallReturn::One(Value::Null))) + }); + let plan = source + .prepare_plan(&program.imports) + .expect("mutated source registry should prepare HTTP plan"); + let mut vm = Vm::new(program); + let error = destination + .bind_vm_with_plan(&mut vm, &plan) + .expect_err("structural sibling mutation must reject the plan"); + assert!(error.to_string().contains("different registry state")); +} + +#[test] +fn cached_plan_refreshes_after_a_sibling_registry_mutation() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let registry = HostFunctionRegistry::new(); + let mut mutating_sibling = registry.clone(); + let destination = registry; + + let mut priming_vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + destination + .bind_vm_cached(&mut priming_vm) + .expect("destination should prime its plan cache"); + mutating_sibling.register_static_args("test::cache_refresh", 0, |_args| { + Ok(CallOutcome::Return(CallReturn::One(Value::Null))) + }); + + let mut refreshed_vm = Vm::new(program); + destination + .bind_vm_cached(&mut refreshed_vm) + .expect("destination should rebuild a plan after sibling mutation"); +} + +#[tokio::test(flavor = "current_thread")] +async fn explicitly_allowed_http_capability_reaches_http_policy() { + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + vm.configure_http(HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![1], + allow_private_ips: true, + ..HttpConfig::default() + }); + let mut registry = HostFunctionRegistry::restricted(); + registry + .allow_builtin("http::client::request") + .expect("HTTP builtin should be explicitly allowlisted"); + registry + .bind_vm_cached(&mut vm) + .expect("explicit capability plan should bind"); + let error = drive_vm_to_halt(&mut vm) + .await + .expect_err("connection failure should reach HTTP runtime"); + assert!(!matches!(error, vm::VmError::UnboundImport(_))); +} + +#[test] +fn http_in_flight_limit_rejects_before_starting_a_request() { + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + vm.set_http_max_in_flight(0); + vm.configure_http(HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![1], + allow_private_ips: true, + + ..HttpConfig::default() + }); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + let error = vm + .run() + .expect_err("zero in-flight capacity must reject the request"); + assert!(error.to_string().contains("in-flight request limit")); +} diff --git a/tests/vm/ownership_tests.rs b/tests/vm/ownership_tests.rs new file mode 100644 index 00000000..ffad0c5c --- /dev/null +++ b/tests/vm/ownership_tests.rs @@ -0,0 +1,385 @@ +//! Milestone 1 of the VM runtime decomposition plan: ownership tests. +//! +//! These tests pin the ownership contract through the public embedding API: +//! - one immutable program can create multiple isolated instances; +//! - run input/events/budgets never leak between runs; +//! - backend caches may be shared without sharing stacks/resources; +//! - reset closes run-scoped state and retains only documented reusable state. + +#[path = "../common/mod.rs"] +mod common; +use common::*; + +use std::sync::{Arc, Mutex}; + +use vm::{HostFunctionRegistry, Value, VmStatus}; + +fn non_yielding_returns_zero(_: &[Value]) -> Result { + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(0)))) +} + +fn non_yielding_returns_seven(_: &[Value]) -> Result { + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(7)))) +} + +fn non_yielding_returns_nine(_: &[Value]) -> Result { + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(9)))) +} + +fn non_yielding_returns_forty_two(_: &[Value]) -> Result { + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(42)))) +} + +struct PendingOneHost; + +impl vm::HostArgsFunction for PendingOneHost { + fn call(&mut self, _args: &[Value]) -> vm::VmResult { + Ok(vm::CallOutcome::Pending(1)) + } +} + +/// One immutable program produces independent instances: each run keeps its own +/// stack, locals, and input, and no instance observes another's execution. +#[test] +fn one_immutable_program_creates_multiple_isolated_instances() { + let program = Arc::new( + compile_source( + r#" + use runtime; + let value: string = runtime::input_json(); + value; + "#, + ) + .expect("source should compile") + .program, + ); + + let mut first = Vm::new_shared(Arc::clone(&program)); + let mut second = Vm::new_shared(Arc::clone(&program)); + HostFunctionRegistry::new() + .bind_vm_cached(&mut first) + .expect("runtime hosts should bind"); + HostFunctionRegistry::new() + .bind_vm_cached(&mut second) + .expect("runtime hosts should bind"); + + first + .set_runtime_input(Value::string("first")) + .expect("input should be accepted"); + second + .set_runtime_input(Value::string("second")) + .expect("input should be accepted"); + + assert_eq!(first.run().expect("first should run"), VmStatus::Halted); + assert_eq!( + first.stack().last(), + Some(&Value::string("\"first\"")), + "first instance must observe its own input" + ); + assert_eq!(second.run().expect("second should run"), VmStatus::Halted); + assert_eq!( + second.stack().last(), + Some(&Value::string("\"second\"")), + "second instance must observe its own input" + ); + assert_eq!( + first.stack().last(), + Some(&Value::string("\"first\"")), + "second's run must not overwrite first's stack" + ); + + // Re-running one instance after reset must not disturb the other. + first.reset_for_reuse(); + first + .set_runtime_input(Value::string("first-again")) + .expect("input should be accepted"); + assert_eq!(first.run().expect("first should rerun"), VmStatus::Halted); + assert_eq!( + second.stack().last(), + Some(&Value::string("\"second\"")), + "first's rerun must not disturb second's stack" + ); + assert_eq!( + first.stack().last(), + Some(&Value::string("\"first-again\"")) + ); +} + +/// Run input and events are run-scoped: a reset closes them, and a later run +/// starts with a clean context. +#[test] +fn run_input_and_events_do_not_leak_between_runs() { + let program = Arc::new( + compile_source( + r#" + use runtime; + let value: string = runtime::input_json(); + runtime::emit_json(value); + value; + "#, + ) + .expect("source should compile") + .program, + ); + let mut vm = Vm::new_shared(Arc::clone(&program)); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("runtime hosts should bind"); + + let seen = Arc::new(Mutex::new(Vec::::new())); + let sink_seen = Arc::clone(&seen); + vm.set_runtime_value_event_sink(move |value: Value| { + sink_seen.lock().expect("sink lock").push(value); + Ok(()) + }) + .expect("event sink should install"); + + vm.set_runtime_input(Value::string("run-one")) + .expect("input should be accepted"); + assert_eq!(vm.run().expect("first run should halt"), VmStatus::Halted); + assert_eq!(vm.stack().last(), Some(&Value::string("\"run-one\""))); + assert_eq!( + seen.lock().expect("sink lock").len(), + 1, + "first run must emit exactly one event" + ); + assert_eq!( + seen.lock().expect("sink lock")[0], + Value::string("\"run-one\"") + ); + + // A reset closes the run-scoped input: the next run must not see the + // previous run's input. + vm.reset_for_reuse(); + let missing = vm.run().expect_err("reset must close run input"); + assert!( + missing.to_string().contains("input_unavailable"), + "unexpected error after reset: {missing:?}" + ); + + // A fresh run (new instance from the same program) with fresh input sees + // neither the old input nor the old event stream. + let mut fresh = Vm::new_shared(Arc::clone(&program)); + HostFunctionRegistry::new() + .bind_vm_cached(&mut fresh) + .expect("runtime hosts should bind"); + let fresh_seen = Arc::new(Mutex::new(Vec::::new())); + let fresh_sink_seen = Arc::clone(&fresh_seen); + fresh + .set_runtime_value_event_sink(move |value: Value| { + fresh_sink_seen.lock().expect("sink lock").push(value); + Ok(()) + }) + .expect("event sink should install"); + fresh + .set_runtime_input(Value::string("run-two")) + .expect("input should be accepted"); + assert_eq!( + fresh.run().expect("fresh run should halt"), + VmStatus::Halted + ); + assert_eq!(fresh.stack().last(), Some(&Value::string("\"run-two\""))); + let events = fresh_seen.lock().expect("sink lock"); + assert_eq!(events.len(), 1, "fresh run must emit exactly one event"); + assert_eq!(events[0], Value::string("\"run-two\"")); +} +/// Fuel budgets are run-scoped: a reset clears the budget, and a new run +/// starts from its configured amount rather than inheriting leftovers. +#[test] +fn fuel_budgets_do_not_leak_between_runs() { + let program = compile_source( + r#" + fn action() -> int; + action(); + "#, + ) + .expect("source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_static_non_yielding_args_function("action", non_yielding_returns_zero); + + // A configured budget reads back as the configured amount. + vm.set_fuel(100); + assert_eq!(vm.get_fuel(), Some(100)); + + // Explicit consumption draws from the run-scoped budget. + vm.consume_fuel(30) + .expect("consumption within budget should succeed"); + assert_eq!(vm.get_fuel(), Some(70)); + + // A run charges the budget; the leftover is less than what was set. + assert_eq!(vm.run().expect("run should halt"), VmStatus::Halted); + let after_run = vm.get_fuel().expect("metering must still be active"); + assert!( + after_run < 70, + "run must consume from the active budget ({after_run} remaining)" + ); + + // Reset must clear the budget entirely (metering disabled, no leftovers). + vm.reset_for_reuse(); + assert_eq!(vm.get_fuel(), None, "reset must clear run-scoped fuel"); + + // A fresh budget starts from the configured amount, not from the prior + // run's leftover. + vm.set_fuel(200); + assert_eq!(vm.run().expect("run should halt"), VmStatus::Halted); + let fresh = vm.get_fuel().expect("metering must still be active"); + assert!( + fresh < 200, + "fresh budget must be consumed from its own amount ({fresh} remaining)" + ); + assert!( + fresh > after_run, + "fresh budget must not inherit the prior run's leftover" + ); +} + +/// The same immutable program can drive multiple VMs with independent stacks +/// and independent backend caches: one VM's reset and rerun never touches +/// another VM's execution state or cached artifacts. +#[test] +fn shared_program_backend_does_not_share_stacks_or_resources() { + let program = Arc::new( + compile_source( + r#" + fn action() -> int; + action(); + "#, + ) + .expect("source should compile") + .program, + ); + + let mut first = Vm::new_shared(Arc::clone(&program)); + let mut second = Vm::new_shared(Arc::clone(&program)); + first.bind_static_non_yielding_args_function("action", non_yielding_returns_seven); + second.bind_static_non_yielding_args_function("action", non_yielding_returns_nine); + + assert_eq!(first.run().expect("first should run"), VmStatus::Halted); + assert_eq!(second.run().expect("second should run"), VmStatus::Halted); + assert_eq!(first.stack(), &[Value::Int(7)]); + assert_eq!(second.stack(), &[Value::Int(9)]); + + // Reset + rerun on one VM must not change the other VM's stack. + first.reset_for_reuse(); + assert_eq!(first.run().expect("first should rerun"), VmStatus::Halted); + assert_eq!(first.stack(), &[Value::Int(7)]); + assert_eq!(second.stack(), &[Value::Int(9)]); +} + +/// Reset closes run-scoped state while retaining documented reusable state: +/// host bindings, backend configuration, and compiled artifacts survive, while +/// the interpreter state (ip/stack/locals) is rewound. +#[test] +fn reset_closes_run_scoped_state_and_retains_reusable_state() { + let program = compile_source( + r#" + fn action() -> int; + action(); + "#, + ) + .expect("source should compile") + .program; + let mut vm = Vm::new(program); + vm.set_regex_cache_capacity(8); + vm.bind_static_non_yielding_args_function("action", non_yielding_returns_forty_two); + + assert_eq!(vm.run().expect("first run should halt"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + vm.reset_for_reuse(); + + // Reusable state survives reset. + assert_eq!( + vm.regex_cache_capacity(), + 8, + "backend cache configuration is reusable across runs" + ); + assert_eq!( + vm.max_script_call_depth(), + vm::DEFAULT_MAX_SCRIPT_CALL_DEPTH, + "interpreter limits are reusable across runs" + ); + + // Run-scoped state is rewound: ip at entry, empty stack, null locals. + assert_eq!(vm.ip(), 0, "reset must rewind the instruction pointer"); + assert!(vm.stack().is_empty(), "reset must clear the stack"); + assert!( + vm.locals().iter().all(|value| *value == Value::Null), + "reset must restore null locals" + ); + + // The retained host binding still executes on the next run. + assert_eq!(vm.run().expect("second run should halt"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); +} + +/// A yield caused by an explicit wait must leave the instance in a state that +/// reset can close, and a subsequent run must not inherit the wait. +#[test] +fn reset_closes_waiting_state_before_the_next_run() { + let program = compile_source( + r#" + fn action() -> int; + action(); + "#, + ) + .expect("source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_args_function("action", Box::new(PendingOneHost)); + + let status = vm.run().expect("run should yield"); + assert_eq!(status, VmStatus::Waiting(1)); + assert_eq!(vm.waiting_host_op_id(), Some(1)); + + vm.reset_for_reuse(); + assert_eq!( + vm.waiting_host_op_id(), + None, + "reset must close the pending host wait" + ); +} + +/// Regression pin for a pre-existing JIT issue that is NOT part of the +/// decomposition: a run that fails inside a host import can leave a native +/// trace/region that corrupts the *same instance's* next run after reset. +/// +/// The bug reproduces on the unmodified tree (HEAD cccdd2f + dirty host work): +/// run a program whose first host call errors, `reset_for_reuse()`, then run +/// again with valid input — the second run can fail with `StackUnderflow` +/// instead of executing. Clearing native traces between the runs makes the +/// rerun behave correctly, which isolates the cause to stale JIT state. +#[test] +#[ignore = "pre-existing JIT stale-trace replay after reset; tracked separately"] +fn reset_after_host_error_reruns_cleanly_on_the_same_instance() { + let program = compile_source( + r#" + use runtime; + let value: string = runtime::input_json(); + runtime::emit_json(value); + value; + "#, + ) + .expect("source should compile") + .program; + let mut vm = Vm::new(program); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("runtime hosts should bind"); + vm.set_runtime_value_event_sink(|_| Ok(())) + .expect("event sink should install"); + + vm.set_runtime_input(Value::string("run-one")) + .expect("input should be accepted"); + assert_eq!(vm.run().expect("first run should halt"), VmStatus::Halted); + + vm.reset_for_reuse(); + let missing = vm.run().expect_err("reset must close run input"); + assert!(missing.to_string().contains("input_unavailable")); + + vm.set_runtime_input(Value::string("run-two")) + .expect("input should be accepted"); + assert_eq!(vm.run().expect("rerun should halt"), VmStatus::Halted); + assert_eq!(vm.stack().last(), Some(&Value::string("\"run-two\""))); +} diff --git a/tests/vm/sqlite_host_tests.rs b/tests/vm/sqlite_host_tests.rs new file mode 100644 index 00000000..4551d827 --- /dev/null +++ b/tests/vm/sqlite_host_tests.rs @@ -0,0 +1,1038 @@ +extern crate vm as rustscript_vm; + +pub mod vm { + + pub use crate::rustscript_vm::{ + CallReturn, HostCallResult, HostOpId, OpCode, Program, Value, VmError, VmMap, VmResult, + }; + + use crate::builtins::runtime::cancellation::{CancellationToken, OperationRegistry}; + use crate::builtins::runtime::resource::ResourceArena; + + #[derive(Clone, Copy, Debug)] + pub struct SqliteLimits { + pub max_connections: usize, + pub max_statements: usize, + pub max_rows: usize, + pub max_columns: usize, + pub max_result_bytes: usize, + pub max_statement_bytes: usize, + pub max_parameters: usize, + pub max_parameter_bytes: usize, + pub max_pending_operations: usize, + pub max_transaction_ms: u64, + pub busy_timeout_ms: u64, + } + + impl Default for SqliteLimits { + fn default() -> Self { + Self { + max_connections: 16, + max_statements: 128, + max_rows: 1_000, + max_columns: 128, + max_result_bytes: 4 * 1024 * 1024, + max_statement_bytes: 1024 * 1024, + max_parameters: 128, + max_parameter_bytes: 1024 * 1024, + max_pending_operations: 32, + max_transaction_ms: 5_000, + busy_timeout_ms: 5_000, + } + } + } + + #[derive(Clone, Debug, Default)] + pub struct SqlitePolicy { + pub database_root: Option, + pub allow_unsafe_sql: bool, + pub limits: SqliteLimits, + } + + pub(crate) struct TestHostRuntime { + pub(crate) runtime_resources: ResourceArena, + pub(crate) runtime_operations: OperationRegistry, + + pub(crate) sqlite_policy: SqlitePolicy, + } + + pub(crate) struct TestRunContext { + pub(crate) cancellation: CancellationToken, + } + + pub struct Vm { + pub(crate) host: TestHostRuntime, + pub(crate) run_ctx: TestRunContext, + } + + impl Vm { + pub fn new(_program: Program) -> Self { + Self { + host: TestHostRuntime { + runtime_resources: ResourceArena::default(), + runtime_operations: OperationRegistry::default(), + + sqlite_policy: SqlitePolicy::default(), + }, + run_ctx: TestRunContext { + cancellation: CancellationToken::root(), + }, + } + } + + pub fn configure_sqlite(&mut self, policy: SqlitePolicy) { + self.host.sqlite_policy = policy; + } + } +} + +mod builtins { + pub use crate::vm::{Value, Vm, VmResult}; + + pub mod runtime { + pub use crate::vm::{HostCallResult, VmMap}; + + pub mod error { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/builtins/runtime/error.rs" + )); + } + + #[allow(dead_code)] + pub mod cancellation { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/builtins/runtime/cancellation.rs" + )); + } + + #[allow(dead_code)] + pub mod resource { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/builtins/runtime/resource.rs" + )); + } + + pub(crate) fn cancel_runtime_operation( + vm: &mut crate::vm::Vm, + op_id: cancellation::OperationId, + reason: cancellation::CancellationReason, + ) { + let payload = vm + .host + .runtime_operations + .get(op_id) + .ok() + .and_then(|operation| operation.payload()); + let _ = vm.host.runtime_operations.cancel(op_id, reason); + if let Some(payload) = payload { + let _ = close_runtime_resource(vm, payload, reason); + } + } + + pub(crate) fn close_runtime_resource( + vm: &mut crate::vm::Vm, + handle: resource::ResourceHandle, + reason: cancellation::CancellationReason, + ) -> error::RuntimeResult { + let operations = vm + .host + .runtime_operations + .operations_for_resource(handle) + .into_iter() + .map(|operation| { + let payload = operation.payload(); + (operation, payload) + }) + .collect::>(); + for (operation, _) in &operations { + operation.token().mark_cancelled(reason); + } + for (operation, _) in &operations { + let _ = vm.host.runtime_operations.cancel(operation.id(), reason); + } + for (_, payload) in operations { + if let Some(payload) = payload { + let _ = close_runtime_resource(vm, payload, reason); + } + } + vm.host.runtime_resources.close(handle, reason) + } + + pub mod typed { + pub type VmArrayRef<'a> = &'a [crate::vm::Value]; + pub type VmMapRef<'a> = &'a crate::vm::VmMap; + } + + pub trait TestBorrowArg<'a>: Sized { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult; + } + + impl<'a> TestBorrowArg<'a> for crate::vm::Value { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + args.get(index) + .cloned() + .ok_or(crate::vm::VmError::HostError(label.to_string())) + } + } + + impl<'a> TestBorrowArg<'a> for i64 { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + match args.get(index) { + Some(crate::vm::Value::Int(value)) => Ok(*value), + _ => Err(crate::vm::VmError::HostError(label.to_string())), + } + } + } + + impl<'a> TestBorrowArg<'a> for &'a str { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + match args.get(index) { + Some(crate::vm::Value::String(value)) => Ok(value.as_str()), + _ => Err(crate::vm::VmError::HostError(label.to_string())), + } + } + } + + impl<'a> TestBorrowArg<'a> for &'a [crate::vm::Value] { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + match args.get(index) { + Some(crate::vm::Value::Array(value)) => Ok(value.as_slice()), + _ => Err(crate::vm::VmError::HostError(label.to_string())), + } + } + } + + impl<'a> TestBorrowArg<'a> for &'a crate::vm::VmMap { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + match args.get(index) { + Some(crate::vm::Value::Map(value)) => Ok(value.as_ref()), + _ => Err(crate::vm::VmError::HostError(label.to_string())), + } + } + } + + pub fn borrow_arg<'a, T: TestBorrowArg<'a>>( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + T::borrow_arg(args, index, label) + } + + pub mod sqlite { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/builtins/runtime/sqlite.rs" + )); + } + + pub mod test_api { + use std::task::{Context, Poll}; + + use super::cancellation::{ + CancellationReason, OperationId, OperationOwner, OperationStatus, + }; + use super::resource::{ResourceHandle, ResourceTypeId}; + use super::{HostCallResult, VmMap}; + use crate::vm::{CallReturn, HostOpId, Value, Vm, VmResult}; + + pub fn open(vm: &mut Vm, args: &[Value]) -> VmResult { + super::sqlite::builtin_sqlite_open(vm, args) + } + + pub fn execute(vm: &mut Vm, args: &[Value]) -> VmResult> { + super::sqlite::builtin_sqlite_execute(vm, args) + } + + pub fn query(vm: &mut Vm, args: &[Value]) -> VmResult> { + super::sqlite::builtin_sqlite_query(vm, args) + } + + pub fn transaction( + vm: &mut Vm, + args: &[Value], + ) -> VmResult>> { + super::sqlite::builtin_sqlite_transaction(vm, args) + } + + pub fn close(vm: &mut Vm, args: &[Value]) -> VmResult<()> { + super::sqlite::builtin_sqlite_close(vm, args) + } + + pub fn poll( + vm: &mut Vm, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + super::sqlite::poll_pending_op(vm, op_id, cx) + } + + pub fn cancel(vm: &mut Vm, op_id: HostOpId) { + let Ok(id) = OperationId::from_raw(op_id) else { + return; + }; + let payload = vm + .host + .runtime_operations + .get(id) + .ok() + .filter(|operation| operation.owner() == OperationOwner::Sqlite) + .and_then(|operation| operation.payload()); + let _ = vm + .host + .runtime_operations + .cancel(id, CancellationReason::Requested); + if let Some(payload) = payload { + let _ = vm + .host + .runtime_resources + .close(payload, CancellationReason::Requested); + } + } + + pub fn active_operation_id(vm: &Vm, resource_id: i64) -> Option { + super::sqlite::active_operation_id(vm, resource_id) + } + + pub fn has_pending(vm: &Vm, op_id: HostOpId) -> bool { + OperationId::from_raw(op_id).is_ok_and(|id| { + vm.host.runtime_operations.get(id).is_ok_and(|operation| { + operation.owner() == OperationOwner::Sqlite + && matches!(operation.status(), OperationStatus::Pending) + && operation.payload().is_some() + }) + }) + } + + pub fn close_all(vm: &mut Vm) { + let _ = vm + .host + .runtime_operations + .cancel_all(CancellationReason::VmReset); + let _ = vm + .host + .runtime_resources + .close_all(CancellationReason::VmReset); + } + + pub fn has_sqlite_operation_owner(vm: &Vm, op_id: HostOpId) -> bool { + OperationId::from_raw(op_id) + .ok() + .and_then(|id| vm.host.runtime_operations.get(id).ok()) + .map(|operation| operation.owner()) + == Some(OperationOwner::Sqlite) + } + + pub fn is_sqlite_resource(handle: i64) -> bool { + ResourceHandle::from_value(&Value::Int(handle)) + .is_ok_and(|handle| handle.resource_type() == ResourceTypeId::SQLITE_CONNECTION) + } + + pub fn insert_wrong_type_resource(vm: &mut Vm) -> i64 { + let handle = vm + .host + .runtime_resources + .insert(ResourceTypeId::IO_FILE, 7_i64) + .expect("test resource should be inserted"); + match handle.as_value() { + Value::Int(value) => value, + _ => unreachable!(), + } + } + } + } +} + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use builtins::runtime::test_api as sqlite; +use vm::{CallReturn, HostCallResult, OpCode, Program, Value, Vm, VmError}; + +struct NoopWake; + +impl Wake for NoopWake { + fn wake(self: Arc) {} +} + +fn noop_waker() -> Waker { + Waker::from(Arc::new(NoopWake)) +} + +fn new_vm() -> Vm { + Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])) +} + +fn temporary_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "rustscript-sqlite-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("temporary SQLite root should be created"); + root +} + +fn map_value(entries: impl IntoIterator) -> Value { + Value::map( + entries + .into_iter() + .map(|(key, value)| (Value::string(key), value)) + .collect(), + ) +} + +fn field<'a>(map: &'a vm::VmMap, key: &str) -> &'a Value { + map.get(&Value::string(key)) + .unwrap_or_else(|| panic!("missing SQLite result field {key}")) +} + +fn open_options(root: &Path, path: &str, mode: &str, limits: Value) -> Value { + map_value([ + ("root", Value::string(root.to_string_lossy().into_owned())), + ("path", Value::string(path)), + ("mode", Value::string(mode)), + ("limits", limits), + ]) +} + +fn limits(entries: impl IntoIterator) -> Value { + map_value( + entries + .into_iter() + .map(|(key, value)| (key, Value::Int(value))), + ) +} + +fn empty_params() -> Value { + Value::array(Vec::new()) +} + +fn wait_pending(vm: &mut Vm, op_id: vm::HostOpId) -> Result { + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + loop { + match sqlite::poll(vm, op_id, &mut cx) { + Poll::Pending => std::thread::yield_now(), + Poll::Ready(Ok(CallReturn::None)) => return Ok(Value::Null), + Poll::Ready(Ok(CallReturn::One(value))) => return Ok(value), + Poll::Ready(Err(error)) => return Err(error), + } + } +} + +fn map_from_value(value: Value) -> vm::VmMap { + let Value::Map(map) = value else { + panic!("SQLite host result should be a map"); + }; + (*map).clone() +} + +fn host_map( + vm: &mut Vm, + result: Result, VmError>, +) -> Result { + let result = result?; + match result { + HostCallResult::Return(map) => Ok(map), + HostCallResult::Pending(op_id) => Ok(map_from_value(wait_pending(vm, op_id)?)), + } +} + +fn host_array( + vm: &mut Vm, + result: Result>, VmError>, +) -> Result, VmError> { + let result = result?; + match result { + HostCallResult::Return(values) => Ok(values), + HostCallResult::Pending(op_id) => { + let Value::Array(values) = wait_pending(vm, op_id)? else { + panic!("SQLite transaction result should be an array"); + }; + Ok((*values).clone()) + } + } +} + +fn open_db(vm: &mut Vm, options: Value) -> i64 { + if let Value::Map(options_map) = &options + && let Some(Value::String(root)) = options_map.get(&Value::string("root")) + { + vm.configure_sqlite(vm::SqlitePolicy { + database_root: Some(root.as_ref().clone()), + ..vm::SqlitePolicy::default() + }); + } + sqlite::open(vm, &[options]).expect("SQLite open should return") +} + +fn execute(vm: &mut Vm, db_id: i64, sql: &str, params: Value) -> Result { + let result = sqlite::execute(vm, &[Value::Int(db_id), Value::string(sql), params]); + host_map(vm, result) +} + +fn query( + vm: &mut Vm, + db_id: i64, + sql: &str, + params: Value, + query_limits: Value, +) -> Result { + let result = sqlite::query( + vm, + &[Value::Int(db_id), Value::string(sql), params, query_limits], + ); + host_map(vm, result) +} + +#[test] +fn sqlite_round_trip_supports_typed_values_and_ordered_transactions() { + let root = temporary_root("round-trip"); + let mut vm = new_vm(); + let db_id = open_db( + &mut vm, + open_options( + &root, + "state.db", + "read_write_create", + limits([ + ("max_rows", 128), + ("max_result_bytes", 64 * 1024), + ("max_statements", 16), + ("max_transaction_ms", 5_000), + ]), + ), + ); + + execute( + &mut vm, + db_id, + "CREATE TABLE values_table (id INTEGER PRIMARY KEY, n INTEGER, r REAL, s TEXT, b BLOB, z TEXT)", + empty_params(), + ) + .expect("table creation should succeed"); + execute( + &mut vm, + db_id, + "INSERT INTO values_table (n, r, s, b, z) VALUES (?1, ?2, ?3, ?4, ?5)", + Value::array(vec![ + Value::Int(7), + Value::Float(1.5), + Value::string("hello"), + Value::bytes(vec![0, 1, 2]), + Value::Null, + ]), + ) + .expect("typed parameter insert should succeed"); + + let rowset = query( + &mut vm, + db_id, + "SELECT n, r, s, b, z FROM values_table ORDER BY id", + empty_params(), + limits([("max_rows", 8), ("max_result_bytes", 64 * 1024)]), + ) + .expect("typed query should succeed"); + assert_eq!( + field(&rowset, "columns"), + &Value::array(vec![ + Value::string("n"), + Value::string("r"), + Value::string("s"), + Value::string("b"), + Value::string("z"), + ]) + ); + assert_eq!(field(&rowset, "truncated"), &Value::Bool(false)); + let Value::Array(rows) = field(&rowset, "rows") else { + panic!("SQLite rows should be an array"); + }; + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0], + Value::array(vec![ + Value::Int(7), + Value::Float(1.5), + Value::string("hello"), + Value::bytes(vec![0, 1, 2]), + Value::Null, + ]) + ); + + let statements = Value::array(vec![ + map_value([ + ( + "sql", + Value::string("INSERT INTO values_table (n) VALUES (?1)"), + ), + ("params", Value::array(vec![Value::Int(8)])), + ]), + map_value([ + ( + "sql", + Value::string("INSERT INTO values_table (n) VALUES (?1)"), + ), + ("params", Value::array(vec![Value::Int(9)])), + ]), + ]); + let transaction = sqlite::transaction(&mut vm, &[Value::Int(db_id), statements]) + .expect("transaction should return"); + let transaction_value = + Value::array(host_array(&mut vm, Ok(transaction)).expect("transaction should complete")); + let Value::Array(results) = transaction_value else { + panic!("transaction should return ordered results"); + }; + assert_eq!(results.len(), 2); + for result in results.iter() { + let Value::Map(result) = result else { + panic!("transaction result should be a map"); + }; + assert_eq!(field(result, "rows_affected"), &Value::Int(1)); + } + + sqlite::close(&mut vm, &[Value::Int(db_id)]).expect("SQLite close should succeed"); + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_enforces_read_only_vm_local_ids_and_sql_safety() { + let root = temporary_root("policy"); + let mut writer_vm = new_vm(); + let db_id = open_db( + &mut writer_vm, + open_options(&root, "state.db", "read_write_create", limits([])), + ); + execute( + &mut writer_vm, + db_id, + "CREATE TABLE items (value INTEGER)", + empty_params(), + ) + .expect("table creation should succeed"); + + let mut other_vm = new_vm(); + let cross_vm_error = sqlite::execute( + &mut other_vm, + &[Value::Int(db_id), Value::string("SELECT 1"), empty_params()], + ) + .expect_err("a SQLite id must not cross VM instances"); + assert!( + cross_vm_error + .to_string() + .contains("unknown SQLite database") + ); + + let unsafe_sql = [ + "ATTACH DATABASE 'other.db' AS other", + "PRAGMA writable_schema = ON", + "SELECT load_extension('not-available')", + "CREATE TABLE first (id INTEGER); CREATE TABLE second (id INTEGER)", + ]; + for sql in unsafe_sql { + let error = sqlite::execute( + &mut writer_vm, + &[Value::Int(db_id), Value::string(sql), empty_params()], + ) + .expect_err("unsafe SQL should be rejected before execution"); + assert!( + error.to_string().contains("not allowed") + || error.to_string().contains("multiple statements") + || error.to_string().contains("disabled"), + "unexpected SQLite policy error: {error}" + ); + } + + let mut read_only_vm = new_vm(); + let read_only_id = open_db( + &mut read_only_vm, + open_options(&root, "state.db", "read_only", limits([])), + ); + let read_only_result = sqlite::execute( + &mut read_only_vm, + &[ + Value::Int(read_only_id), + Value::string("INSERT INTO items (value) VALUES (1)"), + empty_params(), + ], + ); + let read_only_error = host_map(&mut read_only_vm, read_only_result) + .expect_err("read-only SQLite handles must reject writes"); + assert!( + read_only_error.to_string().contains("ReadOnly") + || read_only_error.to_string().contains("readonly") + || read_only_error.to_string().contains("read-only"), + "unexpected read-only error: {read_only_error}" + ); + + sqlite::close_all(&mut writer_vm); + sqlite::close_all(&mut read_only_vm); + sqlite::close_all(&mut other_vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_query_reports_row_and_result_byte_truncation() { + let root = temporary_root("limits"); + let mut vm = new_vm(); + let db_id = open_db( + &mut vm, + open_options( + &root, + "state.db", + "read_write_create", + limits([("max_rows", 32), ("max_result_bytes", 32)]), + ), + ); + execute( + &mut vm, + db_id, + "CREATE TABLE items (value TEXT)", + empty_params(), + ) + .expect("table creation should succeed"); + for value in ["one", "two", "three"] { + execute( + &mut vm, + db_id, + "INSERT INTO items (value) VALUES (?1)", + Value::array(vec![Value::string(value)]), + ) + .expect("row insertion should succeed"); + } + + let row_limited = query( + &mut vm, + db_id, + "SELECT value FROM items ORDER BY rowid", + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ) + .expect("limited query should succeed"); + assert_eq!(field(&row_limited, "truncated"), &Value::Bool(true)); + let Value::Array(rows) = field(&row_limited, "rows") else { + panic!("SQLite rows should be an array"); + }; + assert_eq!(rows.len(), 1); + + let byte_limited = query( + &mut vm, + db_id, + "SELECT value FROM items ORDER BY rowid", + empty_params(), + limits([("max_rows", 32), ("max_result_bytes", 8)]), + ) + .expect("byte-limited query should succeed"); + assert_eq!(field(&byte_limited, "truncated"), &Value::Bool(true)); + + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_pending_operations_can_be_cancelled_and_cleaned_up() { + let root = temporary_root("cancel"); + let mut vm = new_vm(); + let db_id = open_db( + &mut vm, + open_options( + &root, + "state.db", + "read_write_create", + limits([ + ("max_transaction_ms", 10_000), + ("max_result_bytes", 64 * 1024), + ]), + ), + ); + let pending = sqlite::query( + &mut vm, + &[ + Value::Int(db_id), + Value::string( + "WITH RECURSIVE numbers(value) AS (\ + SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 100000000\ + ) SELECT sum(value) FROM numbers", + ), + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ], + ) + .expect("long SQLite query should be scheduled"); + let HostCallResult::Pending(op_id) = pending else { + panic!("long SQLite query should return a pending operation"); + }; + assert!(sqlite::has_pending(&vm, op_id)); + sqlite::cancel(&mut vm, op_id); + assert!(!sqlite::has_pending(&vm, op_id)); + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn cancelling_queued_sqlite_operation_does_not_interrupt_active_sibling() { + let root = temporary_root("queued_cancel"); + let mut vm = new_vm(); + let db_id = open_db( + &mut vm, + open_options( + &root, + "state.db", + "read_write_create", + limits([ + ("max_transaction_ms", 10_000), + ("max_result_bytes", 64 * 1024), + ]), + ), + ); + let active = sqlite::query( + &mut vm, + &[ + Value::Int(db_id), + Value::string( + "WITH RECURSIVE numbers(value) AS (\ + SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 2000000\ + ) SELECT sum(value) FROM numbers", + ), + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ], + ) + .expect("active query should schedule"); + let HostCallResult::Pending(active_id) = active else { + panic!("active query should be pending"); + }; + let wait_deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + while sqlite::active_operation_id(&vm, db_id) != Some(active_id) { + assert!( + std::time::Instant::now() < wait_deadline, + "active query should enter SQLite execution" + ); + std::thread::yield_now(); + } + + let queued = sqlite::query( + &mut vm, + &[ + Value::Int(db_id), + Value::string("SELECT 42"), + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ], + ) + .expect("queued query should schedule"); + let HostCallResult::Pending(queued_id) = queued else { + panic!("queued query should be pending"); + }; + sqlite::cancel(&mut vm, queued_id); + assert_eq!(sqlite::active_operation_id(&vm, db_id), Some(active_id)); + + wait_pending(&mut vm, active_id).expect("active sibling should complete successfully"); + assert!(!sqlite::has_pending(&vm, active_id)); + assert!(!sqlite::has_pending(&vm, queued_id)); + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +fn assert_sqlite_shutdown_cancels_all_siblings(close_all: bool) { + let root = temporary_root(if close_all { + "cancel_all_two_phase" + } else { + "close_two_phase" + }); + let mut vm = new_vm(); + let options = open_options( + &root, + "state.db", + "read_write_create", + limits([ + ("max_transaction_ms", 10_000), + ("max_result_bytes", 64 * 1024), + ]), + ); + let db_id = open_db(&mut vm, options.clone()); + execute( + &mut vm, + db_id, + "CREATE TABLE items (value INTEGER)", + empty_params(), + ) + .expect("table creation should succeed"); + + let active = sqlite::query( + &mut vm, + &[ + Value::Int(db_id), + Value::string( + "WITH RECURSIVE numbers(value) AS (\ + SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 2000000\ + ) SELECT sum(value) FROM numbers", + ), + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ], + ) + .expect("active query should schedule"); + let HostCallResult::Pending(active_id) = active else { + panic!("active query should be pending"); + }; + let wait_deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + while sqlite::active_operation_id(&vm, db_id) != Some(active_id) { + assert!(std::time::Instant::now() < wait_deadline); + std::thread::yield_now(); + } + + let queued = sqlite::execute( + &mut vm, + &[ + Value::Int(db_id), + Value::string("INSERT INTO items (value) VALUES (42)"), + empty_params(), + ], + ) + .expect("queued insert should schedule"); + let HostCallResult::Pending(queued_id) = queued else { + panic!("queued insert should be pending"); + }; + + if close_all { + sqlite::close_all(&mut vm); + } else { + sqlite::close(&mut vm, &[Value::Int(db_id)]).expect("close should succeed"); + } + assert!(!sqlite::has_pending(&vm, active_id)); + assert!(!sqlite::has_pending(&vm, queued_id)); + + let reopened = open_db(&mut vm, options); + let result = query( + &mut vm, + reopened, + "SELECT count(*) AS count FROM items", + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ) + .expect("count query should succeed"); + let Value::Array(rows) = field(&result, "rows") else { + panic!("rows should be an array"); + }; + assert_eq!(rows[0], Value::array(vec![Value::Int(0)])); + + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_close_cancels_all_siblings_before_waiting_for_workers() { + assert_sqlite_shutdown_cancels_all_siblings(false); +} + +#[test] +fn sqlite_cancel_all_broadcasts_before_waiting_for_workers() { + assert_sqlite_shutdown_cancels_all_siblings(true); +} + +#[test] +fn sqlite_uses_typed_generation_checked_resource_handles() { + let root = temporary_root("resource_handles"); + let mut vm = new_vm(); + let first = open_db( + &mut vm, + open_options(&root, "handles.db", "read_write_create", limits([])), + ); + assert!(sqlite::is_sqlite_resource(first)); + + sqlite::close(&mut vm, &[Value::Int(first)]).expect("first handle should close"); + let second = open_db( + &mut vm, + open_options(&root, "handles.db", "read_write_create", limits([])), + ); + assert_ne!( + first, second, + "slot reuse must advance the handle generation" + ); + + let stale = sqlite::execute( + &mut vm, + &[Value::Int(first), Value::string("SELECT 1"), empty_params()], + ) + .expect_err("a closed generation must stay invalid after slot reuse"); + assert!(stale.to_string().contains("unknown SQLite database")); + + let wrong_type = sqlite::insert_wrong_type_resource(&mut vm); + let wrong_type_error = sqlite::execute( + &mut vm, + &[ + Value::Int(wrong_type), + Value::string("SELECT 1"), + empty_params(), + ], + ) + .expect_err("a handle from another resource type must be rejected"); + assert!(wrong_type_error.to_string().contains("wrong resource type")); + + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_pending_work_is_registered_with_the_shared_owner() { + let root = temporary_root("operation_owner"); + let mut vm = new_vm(); + let db_id = open_db( + &mut vm, + open_options(&root, "operations.db", "read_write_create", limits([])), + ); + + let operation = sqlite::execute( + &mut vm, + &[ + Value::Int(db_id), + Value::string("CREATE TABLE items(id INTEGER PRIMARY KEY)"), + empty_params(), + ], + ) + .expect("execute should schedule"); + let HostCallResult::Pending(op_id) = operation else { + panic!("execute should return a pending operation"); + }; + assert!(sqlite::has_sqlite_operation_owner(&vm, op_id)); + let _ = wait_pending(&mut vm, op_id).expect("shared operation should complete"); + assert!(!sqlite::has_pending(&vm, op_id)); + + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} diff --git a/tests/vm/vm_async_runtime_tests.rs b/tests/vm/vm_async_runtime_tests.rs index b58f8fe8..7fb957ad 100644 --- a/tests/vm/vm_async_runtime_tests.rs +++ b/tests/vm/vm_async_runtime_tests.rs @@ -12,8 +12,8 @@ use std::{ use tokio::sync::oneshot; use vm::{ - BytecodeBuilder, CallOutcome, HostAsyncBridge, HostFunction, HostImport, HostOpId, Program, - Value, ValueType, Vm, VmError, VmStatus, + BytecodeBuilder, CallOutcome, CancellationReason, HostAsyncBridge, HostFunction, HostImport, + HostOpId, Program, Value, ValueType, Vm, VmError, VmStatus, }; type AsyncHostResult = Result; @@ -22,6 +22,7 @@ type SharedAsyncOps = Arc>; #[derive(Default)] struct TestAsyncOps { pending: HashMap>, + cancellations: Vec<(HostOpId, CancellationReason)>, } impl TestAsyncOps { @@ -96,6 +97,12 @@ impl HostAsyncBridge for TestAsyncBridge { .pending .remove(&op_id); } + + fn cancel_op_with_reason(&mut self, op_id: HostOpId, reason: CancellationReason) { + let mut ops = self.ops.lock().expect("test async ops lock poisoned"); + ops.pending.remove(&op_id); + ops.cancellations.push((op_id, reason)); + } } struct AsyncAddOneFunction { @@ -134,6 +141,14 @@ impl HostFunction for AsyncAddOneFunction { } } +struct InvalidPendingFunction; + +impl HostFunction for InvalidPendingFunction { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> Result { + Ok(CallOutcome::Pending(0)) + } +} + fn build_async_import_program(input: i64) -> Program { let constants = vec![Value::Int(input)]; let imports = vec![HostImport { @@ -226,8 +241,61 @@ async fn reset_cancels_pending_host_bridge_operation() { )); assert_eq!(ops.lock().unwrap().pending.len(), 1); vm.reset_for_reuse(); - assert_eq!(ops.lock().unwrap().pending.len(), 0); + let ops = ops.lock().unwrap(); + assert_eq!(ops.pending.len(), 0); + assert_eq!(ops.cancellations.len(), 1); + assert_eq!(ops.cancellations[0].1, CancellationReason::VmReset); + drop(ops); + assert_eq!(vm.waiting_host_op_id(), None); +} + +#[test] +fn rejected_pending_result_cancels_bridge_owned_work() { + let ops = Arc::new(Mutex::new(TestAsyncOps::default())); + let mut vm = Vm::new(build_async_import_program(41)); + vm.bind_function("edge::async_add_one", Box::new(InvalidPendingFunction)); + vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops.clone()))); + + vm.run() + .expect_err("zero host operation id should be rejected"); + assert_eq!( + ops.lock().unwrap().cancellations, + vec![(0, CancellationReason::ResourceClosed)] + ); + assert_eq!(vm.waiting_host_op_id(), None); +} + +#[tokio::test(flavor = "current_thread")] +async fn user_cancellation_reaches_host_bridge_and_clears_waiting_state() { + let ops = Arc::new(Mutex::new(TestAsyncOps::default())); + let calls = Arc::new(AtomicUsize::new(0)); + let mut vm = Vm::new(build_async_import_program(41)); + vm.bind_function( + "edge::async_add_one", + Box::new(AsyncAddOneFunction::new( + ops.clone(), + calls, + Duration::from_secs(60), + )), + ); + vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops.clone()))); + + let op_id = match vm.run().expect("pending call") { + VmStatus::Waiting(op_id) => op_id, + status => panic!("expected waiting status, got {status:?}"), + }; + let error = vm + .wait_for_host_op_blocking_with_cancel(|| true) + .expect_err("user cancellation should stop the wait"); + assert!(error.to_string().contains("cancelled")); + let ops = ops.lock().unwrap(); + assert_eq!(ops.pending.len(), 0); + assert_eq!( + ops.cancellations, + vec![(op_id, CancellationReason::Requested)] + ); assert_eq!(vm.waiting_host_op_id(), None); + assert!(op_id > 0); } #[tokio::test(flavor = "current_thread")] diff --git a/tests/vm/vm_runtime_tests.rs b/tests/vm/vm_runtime_tests.rs index f9c6fcb4..8fa22ef1 100644 --- a/tests/vm/vm_runtime_tests.rs +++ b/tests/vm/vm_runtime_tests.rs @@ -23,6 +23,66 @@ fn non_yielding_returns_bool(_: &[Value]) -> Result { Ok(CallOutcome::Return(vm::CallReturn::one(Value::Bool(true)))) } +fn returns_registered_value(_: &[Value]) -> Result { + Ok(CallOutcome::Return(vm::CallReturn::one(Value::Int(42)))) +} + +#[test] +fn empty_registry_allows_functions_registered_by_the_embedder() { + let compiled = + compile_source("fn action() -> int; action();").expect("host call source should compile"); + let mut registry = HostFunctionRegistry::empty(); + registry.register_static_args("action", 0, returns_registered_value); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("custom registry should bind its registered import"); + + assert_eq!( + vm.run().expect("custom host call should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); +} + +#[test] +fn empty_registry_preserves_default_builtin_capabilities() { + let compiled = compile_source("use bytes; bytes::from_array_u8([1, 2, 3]);") + .expect("bytes source should compile"); + let mut vm = Vm::new(compiled.program); + HostFunctionRegistry::empty() + .bind_vm_cached(&mut vm) + .expect("empty registry should bind builtin calls"); + + assert_eq!(vm.run().expect("builtin call should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::bytes(vec![1, 2, 3])]); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn restricted_builtin_capabilities_match_between_interpreter_and_aot() { + let source = "use bytes; bytes::from_array_u8([1, 2, 3]);"; + let program = compile_source(source) + .expect("bytes source should compile") + .program; + + let mut interpreter = Vm::new(program.clone()); + HostFunctionRegistry::restricted() + .bind_vm_cached(&mut interpreter) + .expect("restricted registry should bind"); + assert!(matches!( + interpreter.run(), + Err(vm::VmError::UnboundImport(_)) + )); + + let mut aot = Vm::new(program); + HostFunctionRegistry::restricted() + .bind_vm_cached(&mut aot) + .expect("restricted registry should bind"); + aot.compile_aot().expect("AOT compile should succeed"); + assert!(matches!(aot.run(), Err(vm::VmError::UnboundImport(_)))); +} + #[test] fn non_yielding_args_return_type_contract_is_enforced_before_jit_compilation() { let compiled = @@ -324,6 +384,48 @@ fn namespaced_builtin_io_call_can_be_overridden_by_host_binding() { assert_eq!(vm.stack(), &[Value::Bool(false)]); } +#[test] +fn builtin_override_does_not_bypass_restricted_capability_profile() { + struct ExistsOverride; + + impl HostFunction for ExistsOverride { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> Result { + Ok(CallOutcome::Return(vec![Value::Bool(false)].into())) + } + } + + let program = compile_source( + r#" + use io; + io::exists("request_body"); + "#, + ) + .expect("source should compile") + .program; + + let mut denied = Vm::new(program.clone()); + HostFunctionRegistry::restricted() + .bind_vm_cached(&mut denied) + .expect("restricted registry should bind"); + denied.bind_function("io::exists", Box::new(ExistsOverride)); + assert!(matches!(denied.run(), Err(vm::VmError::UnboundImport(_)))); + + let mut allowed_registry = HostFunctionRegistry::restricted(); + allowed_registry + .allow_builtin("io::exists") + .expect("IO builtin should be known"); + let mut allowed = Vm::new(program); + allowed_registry + .bind_vm_cached(&mut allowed) + .expect("allowlisted registry should bind"); + allowed.bind_function("io::exists", Box::new(ExistsOverride)); + assert_eq!( + allowed.run().expect("override should run"), + VmStatus::Halted + ); + assert_eq!(allowed.stack(), &[Value::Bool(false)]); +} + #[test] fn namespaced_builtin_json_encode_call_can_be_overridden_by_host_binding() { struct JsonEncodeOverride; diff --git a/tests/vm_tests.rs b/tests/vm_tests.rs index 47a3a179..8856a0df 100644 --- a/tests/vm_tests.rs +++ b/tests/vm_tests.rs @@ -1,6 +1,9 @@ #![cfg(feature = "runtime")] #![allow(clippy::duplicate_mod)] +#[path = "vm/ownership_tests.rs"] +mod ownership_tests; + #[path = "vm/drop_contract_tests.rs"] mod drop_contract_tests;