//! WASM tool runtime for managing compiled components. //! //! Follows the principle: compile once at registration, instantiate fresh per execution. //! This matches NEAR blockchain patterns for deterministic, isolated execution. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use tokio::sync::RwLock; use wasmtime::{Config, Engine, OptLevel}; use crate::tools::wasm::error::WasmError; use crate::tools::wasm::limits::{FuelConfig, ResourceLimits}; /// Default epoch tick interval. Each tick increments the engine's epoch counter, /// which causes any store with an expired epoch deadline to trap. pub const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(500); /// Enable wasmtime's persistent compilation cache for a [`Config`]. /// /// On Unix, this delegates to `cache_config_load_default()` which uses a /// shared cache directory. On Windows, each engine gets its own subdirectory /// (keyed by `label`) to avoid OS error 33 (`ERROR_LOCK_VIOLATION`) when /// multiple engines memory-map files in the same cache directory. See #448. /// /// If `explicit_dir` is `Some`, it is used as the cache directory on all /// platforms, bypassing the default. pub fn enable_compilation_cache( wasmtime_config: &mut Config, label: &str, explicit_dir: Option<&Path>, ) -> anyhow::Result<()> { // If the caller provided an explicit directory, or we're on Windows and // need per-engine isolation, write a TOML config with a custom directory. let custom_dir = match explicit_dir { Some(dir) => Some(dir.to_path_buf()), #[cfg(windows)] None => { let base = dirs::cache_dir() .unwrap_or_else(std::env::temp_dir) .join("ironclaw"); Some(base.join(format!("wasmtime-{}", label))) } #[cfg(not(windows))] None => { let _ = label; None } }; match custom_dir { Some(dir) => { std::fs::create_dir_all(&dir)?; let toml_path = dir.join("wasmtime-cache.toml"); let escaped = dir .to_string_lossy() .replace('\\', "\\\\") .replace('"', "\\\""); let toml_content = format!("[cache]\nenabled = true\ndirectory = \"{}\"\n", escaped); std::fs::write(&toml_path, toml_content)?; wasmtime_config.cache_config_load(&toml_path)?; Ok(()) } None => { wasmtime_config.cache_config_load_default()?; Ok(()) } } } /// Configuration for the WASM runtime. #[derive(Debug, Clone)] pub struct WasmRuntimeConfig { /// Default resource limits for tools. pub default_limits: ResourceLimits, /// Fuel configuration. pub fuel_config: FuelConfig, /// Whether to cache compiled modules. pub cache_compiled: bool, /// Directory for compiled module cache. pub cache_dir: Option, /// Cranelift optimization level. pub optimization_level: OptLevel, } impl Default for WasmRuntimeConfig { fn default() -> Self { Self { default_limits: ResourceLimits::default(), fuel_config: FuelConfig::default(), cache_compiled: true, cache_dir: None, optimization_level: OptLevel::Speed, } } } impl WasmRuntimeConfig { /// Create a minimal config for testing. pub fn for_testing() -> Self { Self { default_limits: ResourceLimits::default() .with_memory(1024 * 1024) // 1 MB .with_fuel(100_000) .with_timeout(Duration::from_secs(5)), fuel_config: FuelConfig::with_limit(100_000), cache_compiled: false, cache_dir: None, optimization_level: OptLevel::None, // Faster compilation for tests } } } /// A compiled WASM component ready for instantiation. /// /// Contains the pre-compiled component plus cached metadata extracted /// from the component during preparation. Stores the compiled `Component` /// directly so instantiation doesn't require recompilation. pub struct PreparedModule { /// Tool name. pub name: String, /// Tool description (cached from component). pub description: String, /// Full parameter schema JSON extracted from the component. /// Used for discovery and coercion, not necessarily for the compact /// schema advertised in the main tools array. pub schema: serde_json::Value, /// Pre-compiled component (cheaply cloneable via internal Arc). component: wasmtime::component::Component, /// Resource limits for this tool. pub limits: ResourceLimits, } impl PreparedModule { /// Get the pre-compiled component for instantiation. pub fn component(&self) -> &wasmtime::component::Component { &self.component } } impl std::fmt::Debug for PreparedModule { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PreparedModule") .field("name", &self.name) .field("description", &self.description) .field("limits", &self.limits) .finish() } } /// WASM tool runtime. /// /// Manages the Wasmtime engine and a cache of prepared modules. pub struct WasmToolRuntime { /// Wasmtime engine with configured settings. engine: Engine, /// Runtime configuration. config: WasmRuntimeConfig, /// Cache of prepared modules by name. modules: RwLock>>, } impl WasmToolRuntime { /// Create a new runtime with the given configuration. pub fn new(config: WasmRuntimeConfig) -> Result { let mut wasmtime_config = Config::new(); // Enable fuel consumption for CPU limiting if config.fuel_config.enabled { wasmtime_config.consume_fuel(true); } // Enable epoch interruption as a backup timeout mechanism wasmtime_config.epoch_interruption(true); // Enable component model (WASI Preview 2) wasmtime_config.wasm_component_model(true); // Disable threads (simplifies security model) wasmtime_config.wasm_threads(false); // Set optimization level wasmtime_config.cranelift_opt_level(config.optimization_level); // Disable debug info in production for smaller modules wasmtime_config.debug_info(false); // Enable persistent compilation cache. Wasmtime serializes compiled native // code to disk (~/.cache/wasmtime by default), so subsequent startups // deserialize instead of recompiling — typically 10-50x faster. // // On Windows, each Engine gets its own cache subdirectory to avoid // OS error 33 (ERROR_LOCK_VIOLATION) when multiple engines share the // default cache and Windows holds exclusive locks on memory-mapped // files. See #448. if let Err(e) = enable_compilation_cache(&mut wasmtime_config, "tools", config.cache_dir.as_deref()) { tracing::warn!("Failed to enable wasmtime compilation cache: {}", e); } let engine = Engine::new(&wasmtime_config).map_err(|e| { WasmError::EngineCreationFailed(format!("Failed to create Wasmtime engine: {}", e)) })?; // Spawn a background thread that periodically increments the engine's // epoch counter. Without this, epoch_deadline_trap() never fires and // WASM modules can spin indefinitely even with a deadline set. let ticker_engine = engine.clone(); std::thread::Builder::new() .name("wasm-epoch-ticker".into()) .spawn(move || { loop { std::thread::sleep(EPOCH_TICK_INTERVAL); ticker_engine.increment_epoch(); } }) .map_err(|e| { WasmError::EngineCreationFailed(format!( "Failed to spawn epoch ticker thread: {}", e )) })?; Ok(Self { engine, config, modules: RwLock::new(HashMap::new()), }) } /// Get the Wasmtime engine. pub fn engine(&self) -> &Engine { &self.engine } /// Get the runtime configuration. pub fn config(&self) -> &WasmRuntimeConfig { &self.config } /// Prepare a WASM component for execution. /// /// This validates and compiles the component, extracting metadata. /// The compiled component is cached for fast instantiation. pub async fn prepare( &self, name: &str, wasm_bytes: &[u8], limits: Option, ) -> Result, WasmError> { // Check if already prepared if let Some(module) = self.modules.read().await.get(name) { return Ok(Arc::clone(module)); } let name = name.to_string(); let wasm_bytes = wasm_bytes.to_vec(); let engine = self.engine.clone(); let default_limits = self.config.default_limits.clone(); // Compile in blocking task (Wasmtime compilation is synchronous) let prepared = tokio::task::spawn_blocking(move || { // Validate and compile the component let component = wasmtime::component::Component::new(&engine, &wasm_bytes) .map_err(|e| WasmError::CompilationFailed(e.to_string()))?; // Briefly instantiate to extract metadata (description + schema) // from the tool's exports, analogous to MCP's list_tools(). let effective_limits = limits.clone().unwrap_or(default_limits.clone()); let (description, schema) = crate::tools::wasm::wrapper::extract_wasm_metadata( &engine, &component, &effective_limits, ) .unwrap_or_else(|e| { tracing::warn!( name = %name, error = %e, "WASM metadata extraction failed, using fallbacks" ); ( "WASM sandboxed tool".to_string(), serde_json::json!({ "type": "object", "properties": {}, "additionalProperties": true }), ) }); Ok::<_, WasmError>(PreparedModule { name: name.clone(), description, schema, component, limits: limits.unwrap_or(default_limits), }) }) .await .map_err(|e| WasmError::ExecutionPanicked(format!("Preparation task panicked: {}", e)))??; let prepared = Arc::new(prepared); // Cache the prepared module if self.config.cache_compiled { self.modules .write() .await .insert(prepared.name.clone(), Arc::clone(&prepared)); } tracing::debug!( name = %prepared.name, "Prepared WASM tool for execution" ); Ok(prepared) } /// Get a prepared module by name. pub async fn get(&self, name: &str) -> Option> { self.modules.read().await.get(name).cloned() } /// Remove a prepared module from the cache. pub async fn remove(&self, name: &str) -> Option> { self.modules.write().await.remove(name) } /// List all prepared module names. pub async fn list(&self) -> Vec { self.modules.read().await.keys().cloned().collect() } /// Clear all cached modules. pub async fn clear(&self) { self.modules.write().await.clear(); } } impl std::fmt::Debug for WasmToolRuntime { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WasmToolRuntime") .field("config", &self.config) .field("modules", &">") .finish() } } #[cfg(test)] mod tests { use crate::tools::wasm::limits::ResourceLimits; use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime}; #[test] fn test_runtime_config_default() { let config = WasmRuntimeConfig::default(); assert!(config.cache_compiled); assert!(config.fuel_config.enabled); } #[test] fn test_runtime_config_for_testing() { let config = WasmRuntimeConfig::for_testing(); assert!(!config.cache_compiled); assert_eq!(config.default_limits.memory_bytes, 1024 * 1024); } #[test] fn test_runtime_creation() { let config = WasmRuntimeConfig::for_testing(); let runtime = WasmToolRuntime::new(config).unwrap(); // Engine was created successfully, which validates the config assert!(runtime.config().fuel_config.enabled); } #[tokio::test] async fn test_module_cache_operations() { let config = WasmRuntimeConfig::for_testing(); let runtime = WasmToolRuntime::new(config).unwrap(); // Initially empty assert!(runtime.list().await.is_empty()); assert!(runtime.get("test").await.is_none()); } #[test] fn test_prepared_module_limits() { let limits = ResourceLimits::default() .with_memory(5 * 1024 * 1024) .with_fuel(500_000); assert_eq!(limits.memory_bytes, 5 * 1024 * 1024); assert_eq!(limits.fuel, 500_000); } /// Per-engine cache directories must work correctly to avoid file lock /// conflicts on Windows where multiple engines sharing a single cache /// directory triggers OS error 33 (ERROR_LOCK_VIOLATION). Regression test /// for #448: `enable_compilation_cache` must create a subdirectory and /// produce a valid TOML config that wasmtime can load. #[test] fn test_enable_compilation_cache_with_explicit_dir() { use crate::tools::wasm::runtime::enable_compilation_cache; let tmp = tempfile::tempdir().expect("failed to create temp dir"); let cache_dir = tmp.path().join("custom-cache"); let mut config = wasmtime::Config::new(); enable_compilation_cache(&mut config, "test-engine", Some(cache_dir.as_path())) .expect("enable_compilation_cache should succeed with explicit dir"); // The cache directory should have been created. assert!(cache_dir.exists(), "cache directory should be created"); // A TOML config file should have been written inside. let toml_path = cache_dir.join("wasmtime-cache.toml"); assert!(toml_path.exists(), "TOML config should be written"); let content = std::fs::read_to_string(&toml_path).unwrap(); assert!( content.contains("[cache]"), "TOML must contain [cache] section" ); assert!(content.contains("enabled = true"), "cache must be enabled"); } /// Two engines with different labels must get independent cache directories /// so that their file locks do not conflict. Regression test for #448. #[test] fn test_enable_compilation_cache_label_isolation() { use crate::tools::wasm::runtime::enable_compilation_cache; let tmp = tempfile::tempdir().expect("failed to create temp dir"); let base = tmp.path().join("isolation"); let dir_a = base.join("engine-a"); let dir_b = base.join("engine-b"); let mut config_a = wasmtime::Config::new(); enable_compilation_cache(&mut config_a, "a", Some(dir_a.as_path())) .expect("cache A should succeed"); let mut config_b = wasmtime::Config::new(); enable_compilation_cache(&mut config_b, "b", Some(dir_b.as_path())) .expect("cache B should succeed"); // Both directories must exist and be distinct. assert!(dir_a.exists()); assert!(dir_b.exists()); assert_ne!(dir_a, dir_b); } /// The WASM runtime (Wasmtime engine) must initialise successfully even /// when no tools directory exists on disk. The engine only configures the /// compiler and epoch ticker — loading modules from a directory is a /// separate step. Regression test for a bug where the runtime was gated /// on `tools_dir.exists()`, causing extensions installed after startup /// (e.g. via the web UI) to fail with "WASM runtime not available". #[test] fn test_runtime_creation_without_tools_dir() { let config = WasmRuntimeConfig::for_testing(); // Runtime should succeed even though no tools directory exists. let runtime = WasmToolRuntime::new(config).expect("runtime should init without tools dir"); assert!(runtime.config().fuel_config.enabled); } }