Skip to main content

surrealism_runtime/
runtime.rs

1//! Compiled WASM runtime (shared, immutable, thread-safe).
2//!
3//! # Architecture
4//!
5//! - **`Runtime`**: Compiled WASM component. Thread-safe, shareable (`Arc<Runtime>`). Compile once,
6//!   instantiate many times. Holds a pool of initialized Controllers for reuse. A Tokio semaphore
7//!   caps **checked-out** controllers (in-flight use). Idle controllers in the pool **release**
8//!   their semaphore permits so waiters can proceed; permits are re-acquired when a pooled
9//!   controller is checked out again. `max_pool_size` bounds the pool size and the maximum
10//!   concurrent instances.
11//!
12//! - **`Controller`**: Per-execution instance. Single-threaded. Can be reused across invocations by
13//!   swapping the host context between calls, preserving WASM linear memory (statics, heap).
14//!
15//! # Instance Reuse
16//!
17//! Controllers are pooled inside the Runtime. Between invocations, the host `InvocationContext`
18//! (which carries per-request auth, permissions, KV store) is swapped out. The WASM linear memory
19//! persists, so Rust statics (`OnceLock`, etc.) survive across calls. Security is enforced by the
20//! host context, not by memory isolation — the module never sees user identity directly.
21//!
22//! # Concurrency Patterns
23//!
24//! ```no_run
25//! use std::sync::Arc;
26//! use surrealism_runtime::{runtime::Runtime, package::SurrealismPackage};
27//!
28//! // Compile once (expensive)
29//! let runtime = Arc::new(Runtime::new(package, 8, None, None, None, None)?);
30//!
31//! // For each concurrent request:
32//! let runtime = runtime.clone();
33//! tokio::spawn(async move {
34//!     let context = Box::new(MyContext::new());
35//!     let mut controller = runtime.acquire_controller(context).await?;
36//!     let result = controller.invoke(None, args).await;
37//!     // Return to pool on success; drop on trap
38//!     if result.is_ok() {
39//!         runtime.release_controller(controller);
40//!     }
41//!     result
42//! });
43//! # Ok::<(), surrealism_types::err::SurrealismError>(())
44//! ```
45
46use std::fmt;
47use std::sync::Arc;
48use std::sync::atomic::Ordering;
49use std::time::Duration;
50
51use surrealism_types::err::{PrefixErr, SurrealismError, SurrealismResult};
52use tokio::sync::Semaphore;
53use wasmtime::*;
54use web_time::Instant;
55
56use crate::config::{AbiVersion, SurrealismConfig};
57use crate::controller::Controller;
58use crate::epoch::{self, EngineHandle};
59use crate::exports::ExportsManifest;
60use crate::host::{InvocationContext, implement_host_functions};
61use crate::kv::BTreeMapStore;
62use crate::net_allow::{ResolvedNetAllow, resolve_allow_net};
63use crate::package::{AttachedFs, SurrealismPackage};
64use crate::store::StoreData;
65
66/// Compiled WASM runtime. Thread-safe, can be shared across threads via Arc.
67/// Compiles WASM once, then each controller gets its own isolated Store/Instance.
68/// Holds a pool of initialized controllers for reuse across invocations.
69pub struct Runtime {
70	/// Shared engine handle. Keeps the global epoch ticker alive.
71	engine_handle: EngineHandle,
72	instance_pre: component::InstancePre<StoreData>,
73	config: Arc<SurrealismConfig>,
74	wasm_size: usize,
75	/// Holds the extracted filesystem alive for the lifetime of the runtime.
76	/// When present, its root is mounted as a read-only preopened dir for WASM modules.
77	fs_dir: Option<AttachedFs>,
78	/// Pool of initialized, reusable controllers (retention capped at `max_pool_size`).
79	/// Controllers in the pool have a NullContext and have already run init().
80	/// Uses `parking_lot::Mutex` for non-poisoning, lower-overhead locking.
81	pool: parking_lot::Mutex<Vec<Controller>>,
82	/// Bounds concurrent **in-use** `Controller` instances. Permits are held only while a
83	/// controller is actively checked out; idle pooled controllers release their permits.
84	controller_slots: Arc<Semaphore>,
85	/// Function signatures loaded from the exports manifest at build time.
86	exports: ExportsManifest,
87	/// Per-module KV store shared across all invocations. Persists for the
88	/// lifetime of the Runtime and is passed to each `InvocationContext`.
89	kv_store: Arc<BTreeMapStore>,
90	/// Effective pool size ceiling: `min(server_cap, module_config.unwrap_or(server_cap))`.
91	max_pool_size: usize,
92	/// Effective memory limit: `min(server_cap, module_config)` when both set.
93	max_memory_bytes: Option<usize>,
94	/// Effective per-invocation execution time limit from module config.
95	/// Combined with context timeout and server cap at invoke time.
96	module_execution_time: Option<Duration>,
97	/// `allow_net` resolved once at load (DNS, etc.); shared by WASI and core capabilities.
98	resolved_allow_net: Arc<Vec<ResolvedNetAllow>>,
99}
100
101impl fmt::Debug for Runtime {
102	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103		let pool_size = self.pool.lock().len();
104		f.debug_struct("Runtime")
105			.field("config", &self.config)
106			.field("wasm_size", &self.wasm_size)
107			.field("fs_dir", &self.fs_dir)
108			.field("pool_size", &pool_size)
109			.field("max_pool_size", &self.max_pool_size)
110			.field("max_memory_bytes", &self.max_memory_bytes)
111			.field("module_execution_time", &self.module_execution_time)
112			.field("exported_functions", &self.exports.functions.len())
113			.finish_non_exhaustive()
114	}
115}
116
117impl Runtime {
118	/// Compile the WASM and prepare the runtime.
119	/// This is expensive — do it once and share via `Arc<Runtime>`.
120	///
121	/// `server_pool_size`, `server_max_memory`, `server_max_execution_time`,
122	/// `server_max_kv_entries`, and `server_max_kv_value_bytes` are the
123	/// server-level ceilings from environment variables.
124	pub fn new(
125		SurrealismPackage {
126			wasm,
127			config,
128			exports,
129			fs,
130			logo: _,
131		}: SurrealismPackage,
132		server_pool_size: usize,
133		server_max_memory: Option<usize>,
134		server_max_execution_time: Option<Duration>,
135		server_max_kv_entries: Option<usize>,
136		server_max_kv_value_bytes: Option<usize>,
137	) -> SurrealismResult<Self> {
138		if config.abi != AbiVersion::CURRENT {
139			return Err(SurrealismError::UnsupportedAbi {
140				expected: AbiVersion::CURRENT.0,
141				got: config.abi.0,
142			});
143		}
144
145		let t0 = Instant::now();
146
147		let max_pool_size = config
148			.capabilities
149			.max_pool_size
150			.map(|m| m.min(server_pool_size))
151			.unwrap_or(server_pool_size);
152
153		let max_memory_bytes = match (server_max_memory, config.capabilities.max_memory_bytes) {
154			(Some(s), Some(m)) => Some(s.min(m)),
155			(s, m) => s.or(m),
156		};
157
158		let module_execution_time =
159			match (server_max_execution_time, config.capabilities.max_execution_time) {
160				(Some(s), Some(m)) => Some(s.min(m)),
161				(s, m) => s.or(m),
162			};
163
164		let max_kv_entries = match (server_max_kv_entries, config.capabilities.max_kv_entries) {
165			(Some(s), Some(m)) => Some(s.min(m)),
166			(s, m) => s.or(m),
167		};
168
169		let max_kv_value_bytes =
170			match (server_max_kv_value_bytes, config.capabilities.max_kv_value_bytes) {
171				(Some(s), Some(m)) => Some(s.min(m)),
172				(s, m) => s.or(m),
173			};
174
175		let kv_store = Arc::new(BTreeMapStore::with_limits(max_kv_entries, max_kv_value_bytes));
176
177		let config = Arc::new(config);
178		let wasm_size = wasm.len();
179		tracing::debug!(
180			wasm_size,
181			fs = fs.is_some(),
182			max_pool_size,
183			?max_memory_bytes,
184			?module_execution_time,
185			"Runtime::new starting"
186		);
187
188		let guarded = config.capabilities.strict_timeout;
189		let engine_handle = epoch::shared_engine(guarded);
190		tracing::debug!(
191			strict_timeout = guarded,
192			engine = if guarded {
193				"guarded"
194			} else {
195				"fast"
196			},
197			"Runtime::new: selected engine"
198		);
199		let instance_pre = Self::build(engine_handle.engine(), &wasm)?;
200		tracing::debug!(elapsed = ?t0.elapsed(), "Runtime::new build done");
201
202		let resolved_allow_net = resolve_allow_net(&config.capabilities.allow_net)
203			.prefix_err(|| "Failed to resolve allow_net entries")?;
204
205		let controller_slots = Arc::new(Semaphore::new(max_pool_size.max(1)));
206
207		Ok(Self {
208			engine_handle,
209			instance_pre,
210			config,
211			wasm_size,
212			fs_dir: fs,
213			pool: parking_lot::Mutex::new(Vec::new()),
214			controller_slots,
215			exports,
216			kv_store,
217			max_pool_size,
218			max_memory_bytes,
219			module_execution_time,
220			resolved_allow_net,
221		})
222	}
223
224	/// Returns the size of the original WASM binary in bytes.
225	pub fn wasm_size(&self) -> usize {
226		self.wasm_size
227	}
228
229	/// Returns the per-module KV store. This store is shared across all
230	/// invocations and persists for the lifetime of the Runtime.
231	pub fn kv_store(&self) -> &Arc<BTreeMapStore> {
232		&self.kv_store
233	}
234
235	/// Returns the module configuration.
236	pub fn config(&self) -> &SurrealismConfig {
237		&self.config
238	}
239
240	/// Resolved `allow_net` from module load (same snapshot used for WASI socket filtering).
241	pub fn resolved_allow_net(&self) -> Arc<Vec<ResolvedNetAllow>> {
242		Arc::clone(&self.resolved_allow_net)
243	}
244
245	/// Compute the maximum epoch delta that won't overflow when wasmtime adds
246	/// it to the current epoch. Wasmtime uses wrapping `+` internally in
247	/// `set_epoch_deadline`, so `u64::MAX` overflows once the epoch > 0.
248	/// We subtract the shadow counter (which is always >= the real engine
249	/// epoch) plus a small margin to absorb any ticks that land between
250	/// the load and the `set_epoch_deadline` call.
251	pub(crate) fn epoch_deadline_max(&self) -> u64 {
252		let epoch = self.engine_handle.epoch_counter().load(Ordering::Acquire);
253		u64::MAX.saturating_sub(epoch).saturating_sub(1)
254	}
255
256	fn build(engine: &Engine, wasm: &[u8]) -> SurrealismResult<component::InstancePre<StoreData>> {
257		let t0 = Instant::now();
258
259		let comp = component::Component::new(engine, wasm)
260			.prefix_err(|| "Failed to construct component from bytes")?;
261		tracing::debug!(elapsed = ?t0.elapsed(), "build: Component::new");
262
263		let t1 = Instant::now();
264		let mut linker: component::Linker<StoreData> = component::Linker::new(engine);
265		wasmtime_wasi::p2::add_to_linker_async(&mut linker)
266			.prefix_err(|| "failed to add WASI P2 to component linker")?;
267		implement_host_functions(&mut linker)
268			.prefix_err(|| "failed to implement host functions")?;
269		tracing::debug!(elapsed = ?t1.elapsed(), "build: linker setup");
270
271		let t2 = Instant::now();
272		let instance_pre = linker
273			.instantiate_pre(&comp)
274			.prefix_err(|| "failed to pre-instantiate component (import resolution)")?;
275		tracing::debug!(elapsed = ?t2.elapsed(), "build: instantiate_pre");
276
277		tracing::debug!(elapsed = ?t0.elapsed(), "build: total");
278		Ok(instance_pre)
279	}
280
281	/// Acquire a controller ready for invocation. Reuses a pooled controller if available
282	/// (preserving WASM memory / statics from prior runs), otherwise creates and initializes
283	/// a fresh one — waiting on [`Semaphore`] if `max_pool_size` controllers are already checked
284	/// out. The supplied context is installed before returning.
285	///
286	/// The semaphore permit is acquired **before** checking the pool so that a
287	/// popped controller is never outstanding without a matching permit.
288	#[tracing::instrument(skip_all)]
289	pub async fn acquire_controller(
290		&self,
291		context: Box<dyn InvocationContext>,
292	) -> SurrealismResult<Controller> {
293		let permit = self.acquire_slot().await?;
294
295		let pooled = {
296			let mut pool = self.pool.lock();
297			let size = pool.len();
298			let ctrl = pool.pop();
299			tracing::debug!(
300				pool_size_before = size,
301				got_pooled = ctrl.is_some(),
302				"acquire_controller: pool.pop()"
303			);
304			ctrl
305		};
306
307		match pooled {
308			Some(mut ctrl) => {
309				tracing::debug!("acquire_controller: reusing pooled controller");
310				ctrl.attach_controller_slot(permit);
311				ctrl.reset_epoch_deadline();
312				ctrl.set_context(context);
313				Ok(ctrl)
314			}
315			None => {
316				tracing::info!("acquire_controller: creating NEW controller + init()");
317				let mut ctrl = self.create_controller(context, permit).await?;
318				ctrl.init().await?;
319				Ok(ctrl)
320			}
321		}
322	}
323
324	/// Return a controller to the pool for reuse. The invocation context is cleared
325	/// (replaced with a NullContext) so no per-request state is retained on the host side.
326	/// WASM linear memory (statics, heap) is preserved for the next invocation.
327	///
328	/// Do NOT release a controller after a WASM trap — drop it instead to discard
329	/// potentially inconsistent instance state.
330	pub fn release_controller(&self, mut controller: Controller) {
331		controller.clear_context();
332		// Idle pool slots must not hold semaphore permits, or `acquire_owned` starves once the
333		// pool fills even though controllers are available (see controller pool + semaphore
334		// design).
335		drop(controller.take_controller_slot());
336		let mut pool = self.pool.lock();
337		if pool.len() < self.max_pool_size {
338			tracing::debug!(
339				pool_size_after = pool.len() + 1,
340				max_pool_size = self.max_pool_size,
341				"release_controller: returned to pool"
342			);
343			pool.push(controller);
344		} else {
345			tracing::info!(
346				pool_size = pool.len(),
347				max_pool_size = self.max_pool_size,
348				"release_controller: pool full, dropping controller"
349			);
350		}
351	}
352
353	/// Look up a function signature from the exports manifest.
354	pub fn get_signature(
355		&self,
356		sub: Option<&str>,
357	) -> SurrealismResult<&crate::exports::FunctionExport> {
358		self.exports.get_signature(sub).ok_or_else(|| {
359			let name = sub.unwrap_or("<default>");
360			SurrealismError::Other(anyhow::anyhow!(
361				"function '{name}' not found in exports manifest"
362			))
363		})
364	}
365
366	/// Access the full exports manifest.
367	pub fn exports(&self) -> &ExportsManifest {
368		&self.exports
369	}
370
371	/// Create a new Controller with its own isolated Store and Instance.
372	/// Import resolution is already done (in `InstancePre`); this only allocates
373	/// memory, initializes state, and runs any start functions.
374	///
375	/// Prefer `acquire_controller` for the reuse path. This is the low-level constructor.
376	#[tracing::instrument(skip_all)]
377	pub async fn new_controller(
378		&self,
379		context: Box<dyn InvocationContext>,
380	) -> SurrealismResult<Controller> {
381		let permit = self.acquire_slot().await?;
382		self.create_controller(context, permit).await
383	}
384
385	async fn acquire_slot(&self) -> SurrealismResult<tokio::sync::OwnedSemaphorePermit> {
386		Arc::clone(&self.controller_slots).acquire_owned().await.map_err(|_| {
387			SurrealismError::Other(anyhow::anyhow!(
388				"Surrealism controller semaphore closed (runtime shutdown?)"
389			))
390		})
391	}
392
393	/// Inner constructor that takes a pre-acquired semaphore permit.
394	#[tracing::instrument(skip_all)]
395	async fn create_controller(
396		&self,
397		context: Box<dyn InvocationContext>,
398		controller_slot: tokio::sync::OwnedSemaphorePermit,
399	) -> SurrealismResult<Controller> {
400		let t0 = Instant::now();
401
402		let fs_root = self.fs_dir.as_ref().map(|fs| fs.path());
403		let stdout_cb = crate::wasi_context::new_stdout_callback();
404		let stderr_cb = crate::wasi_context::new_stderr_callback();
405		*stdout_cb.lock() = context.stdout_callback();
406		*stderr_cb.lock() = context.stderr_callback();
407		let (wasi_ctx, table) = crate::wasi_context::build(
408			fs_root,
409			Arc::clone(&self.resolved_allow_net),
410			Arc::clone(&stdout_cb),
411			Arc::clone(&stderr_cb),
412		)?;
413		tracing::debug!(elapsed = ?t0.elapsed(), "new_controller: wasi_context::build");
414
415		let mut limits_builder = StoreLimitsBuilder::new();
416		if let Some(max_mem) = self.max_memory_bytes {
417			limits_builder = limits_builder.memory_size(max_mem);
418		}
419		let limiter = limits_builder.build();
420
421		let store_data = StoreData {
422			wasi: wasi_ctx,
423			table,
424			config: Arc::clone(&self.config),
425			context,
426			limiter,
427			stdout_cb,
428			stderr_cb,
429		};
430		let mut store = Store::new(self.engine_handle.engine(), store_data);
431		store.limiter(|data| &mut data.limiter);
432		store.set_epoch_deadline(self.epoch_deadline_max());
433
434		let t1 = Instant::now();
435		let instance = self
436			.instance_pre
437			.instantiate_async(&mut store)
438			.await
439			.map_err(SurrealismError::Instantiation)?;
440		tracing::debug!(elapsed = ?t1.elapsed(), "new_controller: instantiate_async");
441
442		let t2 = Instant::now();
443
444		let invoke_fn = instance.get_func(&mut store, "invoke").ok_or_else(|| {
445			SurrealismError::Other(anyhow::anyhow!(
446				"component is missing required export 'invoke'. \
447				 Ensure the module is built with `surreal module build`"
448			))
449		})?;
450
451		let args_fn = instance.get_func(&mut store, "function-args");
452		let returns_fn = instance.get_func(&mut store, "function-returns");
453		let list_fn = instance.get_func(&mut store, "list-functions");
454		let writeable_fn = instance.get_func(&mut store, "function-writeable");
455		let comment_fn = instance.get_func(&mut store, "function-comment");
456		let init_fn = instance.get_func(&mut store, "init");
457
458		tracing::debug!(
459			elapsed = ?t2.elapsed(),
460			has_invoke = true,
461			has_args = args_fn.is_some(),
462			has_returns = returns_fn.is_some(),
463			has_list = list_fn.is_some(),
464			has_writeable = writeable_fn.is_some(),
465			has_comment = comment_fn.is_some(),
466			has_init = init_fn.is_some(),
467			"new_controller: export lookup"
468		);
469		tracing::info!(elapsed = ?t0.elapsed(), "new_controller: total");
470
471		Ok(Controller::new(
472			store,
473			invoke_fn,
474			args_fn,
475			returns_fn,
476			list_fn,
477			writeable_fn,
478			comment_fn,
479			init_fn,
480			self.module_execution_time,
481			Arc::clone(self.engine_handle.epoch_counter()),
482			controller_slot,
483		))
484	}
485}