Building the Execution Engine
The first layer of the runtime: compiling WAT, caching modules, and enforcing safety limits.
We can write WAT programs. Now we need to run them from Rust. This article builds the first layer of our runtime: the execution engine that compiles WAT to runnable modules, caches them, and enforces safety limits.
Project Setup
We're building a Cargo workspace. The engine lives in the rt (runtime) crate:
workspace/
├── Cargo.toml # Workspace root
├── protocol/ # Wire format (next article)
└── rt/ # Runtime engine (this article)
├── Cargo.toml
└── src/
├── lib.rs
├── engine.rs
├── module.rs
├── error.rs
└── status.rsThe workspace Cargo.toml:
[workspace]
members = ["protocol", "rt"]
resolver = "2"
[workspace.dependencies]
wasmtime = "28.0.0"
anyhow = "1"The rt/Cargo.toml:
[package]
name = "rt"
version = "0.1.0"
edition = "2021"
[dependencies]
wasmtime = { workspace = true }
anyhow = { workspace = true }
protocol = { path = "../protocol" }Error Types
Before building the engine, we need a vocabulary for what can go wrong. Every error a WASM program can produce falls into one of these categories:
// rt/src/error.rs
use std::fmt;
#[derive(Debug)]
pub enum Error {
MemoryExhausted { requested: u32, limit: u64 },
InvalidPointer { ptr: u32, size: u32 },
ArgOutOfBounds { index: u32, count: u32 },
Serialization(protocol::Error),
Compilation(String),
FuelExhausted,
Trap(String),
MissingImport { module: String, name: String },
Other(anyhow::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::MemoryExhausted { requested, limit } => {
write!(f, "memory exhausted (requested {requested} bytes, limit {limit})")
}
Error::InvalidPointer { ptr, size } => {
write!(f, "invalid pointer {ptr:#x} (memory size {size})")
}
Error::ArgOutOfBounds { index, count } => {
write!(f, "argument {index} out of bounds ({count} available)")
}
Error::Serialization(e) => write!(f, "serialization: {e}"),
Error::Compilation(msg) => write!(f, "compilation failed: {msg}"),
Error::FuelExhausted => write!(f, "fuel exhausted"),
Error::Trap(msg) => write!(f, "wasm trap: {msg}"),
Error::MissingImport { module, name } => {
write!(f, "missing import {module}.{name}")
}
Error::Other(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Serialization(e) => Some(e),
_ => None,
}
}
}
impl From<protocol::Error> for Error {
fn from(e: protocol::Error) -> Self {
Error::Serialization(e)
}
}
impl From<anyhow::Error> for Error {
fn from(e: anyhow::Error) -> Self {
let full = format!("{e:?}");
if full.contains("all fuel consumed") {
Error::FuelExhausted
} else if full.contains("unknown import") {
let (module, name) = parse_unknown_import(&full);
Error::MissingImport { module, name }
} else {
Error::Other(e)
}
}
}
fn parse_unknown_import(msg: &str) -> (String, String) {
if let Some(start) = msg.find('`') {
if let Some(end) = msg[start + 1..].find('`') {
let import = &msg[start + 1..start + 1 + end];
if let Some(sep) = import.find("::") {
return (
import[..sep].to_string(),
import[sep + 2..].to_string(),
);
}
}
}
("unknown".to_string(), "unknown".to_string())
}
pub type Result<T> = std::result::Result<T, Error>;The From<anyhow::Error> implementation is worth noting. Wasmtime reports fuel exhaustion and unknown imports as anyhow::Error with specific message patterns. We parse the message to convert these into structured error variants. This means callers can match on Error::FuelExhausted instead of inspecting error strings.
Status Codes
WASM programs return an i32 exit code. We define a convention:
// rt/src/status.rs
pub const SUCCESS: u32 = 0;
pub const ETRFM: u32 = 1; // Transformation failed
pub const ENOMEM: u32 = 2; // Memory allocation error
pub const EACCESS: u32 = 3; // Permission denied
pub const ENOENT: u32 = 4; // No such entry
pub const EBOUND: u32 = 5; // Index out of bounds
pub const EREMOTE: u32 = 6; // Remote request failed
pub const EPARSE: u32 = 7; // Parse errorThese mirror Unix errno conventions. A program returns 0 for success, and a specific non-zero code for each failure mode. The orchestrator (in later articles) reads these codes to decide what to tell the LLM.
The Module Wrapper
A compiled module carries both the wasmtime module and its content hash:
// rt/src/module.rs
#[derive(Clone)]
pub struct Module {
inner: wasmtime::Module,
pub(crate) hash: u64,
}
impl Module {
pub(crate) fn new(inner: wasmtime::Module, hash: u64) -> Self {
Self { inner, hash }
}
pub fn inner(&self) -> &wasmtime::Module {
&self.inner
}
pub fn hash(&self) -> u64 {
self.hash
}
}The hash serves as a cache key. Two modules compiled from identical WAT text will have the same hash, so we only need to compile once.
The Engine
Now the centerpiece. The Engine wraps wasmtime's engine with three additions: a configuration object, a module cache, and cheap clonability:
// rt/src/engine.rs
use crate::error::{Error, Result};
use crate::module::Module;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, RwLock};
pub struct EngineConfig {
/// Maximum linear memory in bytes. Default: 16MB.
pub max_memory_bytes: u64,
/// Initial memory pages. Default: 1 (64KB).
pub initial_pages: u32,
/// Fuel limit (instruction count). None = unlimited.
pub fuel: Option<u64>,
/// Enable async support. Default: true.
pub async_support: bool,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
max_memory_bytes: 16 * 1024 * 1024,
initial_pages: 1,
fuel: None,
async_support: true,
}
}
}
#[derive(Clone)]
pub struct Engine {
inner: wasmtime::Engine,
config: Arc<EngineConfig>,
cache: Arc<RwLock<HashMap<u64, wasmtime::Module>>>,
}The #[derive(Clone)] works because all fields are Arc-wrapped. Cloning an Engine is just incrementing reference counts — no data is copied.
Constructing the Engine
impl Engine {
pub fn new(config: EngineConfig) -> Result<Self> {
let mut wt_config = wasmtime::Config::new();
wt_config.async_support(config.async_support);
if config.fuel.is_some() {
wt_config.consume_fuel(true);
}
let inner = wasmtime::Engine::new(&wt_config).map_err(Error::Other)?;
Ok(Engine {
inner,
config: Arc::new(config),
cache: Arc::new(RwLock::new(HashMap::new())),
})
}Two wasmtime configuration knobs matter:
async_support: Enablescall_asyncon functions, which we need because some host functions (HTTP requests, AI inference) are async.consume_fuel: When enabled, every WASM instruction deducts from a fuel counter. When fuel hits zero, execution traps with "all fuel consumed." This is our hard guarantee against infinite loops.
Compilation with Caching
pub fn compile_wat(&self, wat: &str) -> Result<Module> {
let hash = hash_bytes(wat.as_bytes());
{
let cache = self.cache.read().unwrap();
if let Some(cached) = cache.get(&hash) {
return Ok(Module::new(cached.clone(), hash));
}
}
let module = wasmtime::Module::new(&self.inner, wat)
.map_err(|e| Error::Compilation(e.to_string()))?;
self.cache.write().unwrap().insert(hash, module.clone());
Ok(Module::new(module, hash))
}The pattern:
- Hash the WAT source bytes
- Check the cache (read lock — cheap, allows concurrent reads)
- On miss: compile, upgrade to write lock, insert
- Return the module wrapped with its hash
wasmtime::Module::new() handles both WAT text and WASM binary. We also provide compile_wasm() for pre-compiled binaries — identical logic, different input type.
The Hash Function
fn hash_bytes(bytes: &[u8]) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
hasher.finish()
}DefaultHasher is not cryptographic, but it doesn't need to be. We're hashing for cache deduplication, not for security. Two identical WAT programs produce the same hash; different programs almost certainly produce different hashes. Collisions mean a cache miss, not a security hole.
Testing the Engine
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compile_minimal_wat() {
let engine = Engine::new(EngineConfig::default()).unwrap();
let wat = r#"(module
(memory (export "mem.tape") 1)
(func (export "run") (result i32) (i32.const 42))
)"#;
let _module = engine.compile_wat(wat).unwrap();
}
#[test]
fn compile_caches() {
let engine = Engine::new(EngineConfig::default()).unwrap();
let wat = r#"(module
(memory (export "mem.tape") 1)
(func (export "run") (result i32) (i32.const 0))
)"#;
engine.compile_wat(wat).unwrap();
engine.compile_wat(wat).unwrap();
assert_eq!(engine.cache.read().unwrap().len(), 1);
}
#[test]
fn compile_bad_wat() {
let engine = Engine::new(EngineConfig::default()).unwrap();
let result = engine.compile_wat("(not valid wat)");
assert!(result.is_err());
}
}Three things to verify:
- Valid WAT compiles without error
- Compiling the same WAT twice produces only one cache entry
- Invalid WAT returns
Error::Compilation
Run these with cargo test -p rt.
What We Have So Far
After this article, our runtime can:
- Accept WAT text
- Compile it to a runnable module via wasmtime
- Cache compiled modules by content hash
- Enforce fuel limits via engine configuration
- Report structured errors for compilation failures, fuel exhaustion, and missing imports
What it can't do yet: actually run a module. Running requires creating a Store (wasmtime's execution context), which requires a State object, which requires memory management. That's the next article.
The Compilation Pipeline (So Far)
WAT text ──▶ hash_bytes() ──▶ cache lookup
│
hit?────┤────miss?
│ │
return cached wasmtime::Module::new()
│
insert into cache
│
return ModuleIn later articles, this pipeline will extend:
WAT body ──▶ preprocess() ──▶ Template.assemble() ──▶ Engine.compile_wat()
──▶ Linker.pre_link() ──▶ LinkedModule.instantiate() ──▶ Instance.run()But each step builds on the previous one. For now, we have the foundation: compile and cache.