use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
time::Duration,
};
use tocat_api::{Boundaries, Needs, PluginError, Result};
use wasmtime::{
Config, Engine, Instance, InstancePre, Linker, Memory, Module, Store, StoreLimits,
StoreLimitsBuilder, TypedFunc, WasmParams, WasmResults,
};
use super::{
NAME,
abi::{self, ABI_VERSION, Outbox},
};
pub struct HostState {
limits: StoreLimits,
}
fn engine() -> &'static Engine {
static ENGINE: OnceLock<Engine> = OnceLock::new();
ENGINE.get_or_init(|| {
let mut config = Config::new();
config.consume_fuel(true);
Engine::new(&config).expect("wasmtime engine with default settings")
})
}
type Cache = Mutex<HashMap<PathBuf, InstancePre<HostState>>>;
fn cache() -> &'static Cache {
static CACHE: OnceLock<Cache> = OnceLock::new();
CACHE.get_or_init(Cache::default)
}
pub fn load(path: &Path) -> Result<InstancePre<HostState>> {
let path = path
.canonicalize()
.map_err(|e| config_error(format!("{}: {e}", path.display())))?;
let mut cache = cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(pre) = cache.get(&path) {
return Ok(pre.clone());
}
let module = Module::from_file(engine(), &path)
.map_err(|e| config_error(format!("{}: {e}", path.display())))?;
let pre = prepare(&module).map_err(|e| config_error(format!("{}: {e}", path.display())))?;
cache.insert(path, pre.clone());
Ok(pre)
}
#[cfg(test)]
pub fn compile(bytes: impl AsRef<[u8]>) -> Result<InstancePre<HostState>> {
let module = Module::new(engine(), bytes).map_err(|e| config_error(e.to_string()))?;
prepare(&module)
}
fn prepare(module: &Module) -> Result<InstancePre<HostState>> {
if let Some(import) = module.imports().next() {
return Err(config_error(format!(
"guest imports {}::{}, but tocat guests import nothing. Effects are \
queued in the outbox and applied by the host, so a guest needs no \
host functions and cannot be built against WASI",
import.module(),
import.name(),
)));
}
Linker::new(engine())
.instantiate_pre(module)
.map_err(|e| config_error(e.to_string()))
}
pub struct Guest {
store: Store<HostState>,
memory: Memory,
fuel: u64,
alloc: TypedFunc<i32, i32>,
outbox: TypedFunc<(), i32>,
on_bytes: TypedFunc<(i32, i32), ()>,
on_eof: Option<TypedFunc<(), ()>>,
on_tick: Option<TypedFunc<(), ()>>,
tick_interval: Option<Duration>,
boundaries: Boundaries,
needs: Needs,
}
impl Guest {
pub fn new(
pre: &InstancePre<HostState>,
memory_max: usize,
fuel: u64,
config: &[u8],
) -> Result<Self> {
let state = HostState {
limits: StoreLimitsBuilder::new().memory_size(memory_max).build(),
};
let mut store = Store::new(engine(), state);
store.limiter(|state| &mut state.limits);
set_fuel(&mut store, fuel)?;
let instance = pre
.instantiate(&mut store)
.map_err(|e| config_error(format!("instantiating: {e}")))?;
let memory = instance
.get_memory(&mut store, "memory")
.ok_or_else(|| config_error("guest exports no memory"))?;
let version: TypedFunc<(), i32> = required(&instance, &mut store, "tocat_abi_version")?;
let version = version
.call(&mut store, ())
.map_err(|e| config_error(format!("tocat_abi_version: {e}")))?;
if version != ABI_VERSION {
return Err(config_error(format!(
"guest speaks ABI version {version}, this build speaks {ABI_VERSION}"
)));
}
let mut guest = Self {
memory,
fuel,
alloc: required(&instance, &mut store, "tocat_alloc")?,
outbox: required(&instance, &mut store, "tocat_outbox")?,
on_bytes: required(&instance, &mut store, "tocat_on_bytes")?,
on_eof: optional(&instance, &mut store, "tocat_on_eof"),
on_tick: optional(&instance, &mut store, "tocat_on_tick"),
tick_interval: None,
boundaries: Boundaries::Fuse,
needs: Needs::Nothing,
store,
};
if let Some(init) = optional::<(i32, i32), ()>(&instance, &mut guest.store, "tocat_init") {
let ptr = guest.write(config)?;
let len = config.len() as i32;
set_fuel(&mut guest.store, fuel)?;
init.call(&mut guest.store, (ptr, len))
.map_err(|e| config_error(format!("tocat_init: {e}")))?;
let outbox = guest.outbox()?;
if outbox.has(abi::FLAG_ERROR) {
let message = abi::slice(guest.memory(), outbox.message.ptr, outbox.message.len)?;
return Err(config_error(String::from_utf8_lossy(message).into_owned()));
}
}
guest.tick_interval =
optional::<(), i64>(&instance, &mut guest.store, "tocat_tick_interval_ns")
.and_then(|func| func.call(&mut guest.store, ()).ok())
.and_then(|nanos| u64::try_from(nanos).ok())
.filter(|nanos| *nanos > 0)
.map(Duration::from_nanos);
if let Some(func) = optional::<(), i32>(&instance, &mut guest.store, abi::BOUNDARIES) {
let raw = func
.call(&mut guest.store, ())
.map_err(|e| config_error(format!("{}: {e}", abi::BOUNDARIES)))?;
let (boundaries, needs) = u32::try_from(raw)
.ok()
.and_then(abi::unpack_boundaries)
.ok_or_else(|| {
config_error(format!(
"{} returned {raw}, which this build does not understand: \
the guest was built against a later ABI",
abi::BOUNDARIES,
))
})?;
guest.boundaries = boundaries;
guest.needs = needs;
}
Ok(guest)
}
pub fn tick_interval(&self) -> Option<Duration> {
self.tick_interval.filter(|_| self.on_tick.is_some())
}
pub fn boundaries(&self) -> Boundaries {
self.boundaries
}
pub fn needs(&self) -> Needs {
self.needs
}
pub fn on_bytes(&mut self, input: &[u8]) -> Result<()> {
let ptr = self.write(input)?;
let call = &self.on_bytes;
set_fuel(&mut self.store, self.fuel)?;
call.call(&mut self.store, (ptr, input.len() as i32))
.map_err(|e| trap("tocat_on_bytes", &e))
}
pub fn on_eof(&mut self) -> Result<()> {
let Some(call) = &self.on_eof else {
return Ok(());
};
set_fuel(&mut self.store, self.fuel)?;
call.call(&mut self.store, ())
.map_err(|e| trap("tocat_on_eof", &e))
}
pub fn on_tick(&mut self) -> Result<()> {
let Some(call) = &self.on_tick else {
return Ok(());
};
set_fuel(&mut self.store, self.fuel)?;
call.call(&mut self.store, ())
.map_err(|e| trap("tocat_on_tick", &e))
}
pub fn outbox(&mut self) -> Result<Outbox> {
let at = self
.outbox
.call(&mut self.store, ())
.map_err(|e| trap("tocat_outbox", &e))?;
Outbox::read(self.memory.data(&self.store), at as u32)
}
pub fn memory(&self) -> &[u8] {
self.memory.data(&self.store)
}
fn write(&mut self, bytes: &[u8]) -> Result<i32> {
let len = i32::try_from(bytes.len())
.map_err(|_| PluginError::runtime(NAME, "chunk too large for a 32-bit guest"))?;
let alloc = &self.alloc;
set_fuel(&mut self.store, self.fuel)?;
let ptr = alloc
.call(&mut self.store, len)
.map_err(|e| trap("tocat_alloc", &e))?;
if ptr <= 0 {
return Err(PluginError::runtime(
NAME,
format!(
"guest refused a chunk of {len} bytes. A guest that meant to \
accept it may be returning an offset into its own arena \
rather than an address in its linear memory"
),
));
}
self.memory
.write(&mut self.store, ptr as usize, bytes)
.map_err(|e| {
PluginError::runtime(NAME, format!("writing {len} bytes into the guest: {e}"))
})
.map(|()| ptr)
}
}
fn required<P, R>(
instance: &Instance,
store: &mut Store<HostState>,
export: &str,
) -> Result<TypedFunc<P, R>>
where
P: WasmParams,
R: WasmResults,
{
instance
.get_typed_func(store, export)
.map_err(|e| config_error(format!("{export}: {e}")))
}
fn optional<P, R>(
instance: &Instance,
store: &mut Store<HostState>,
export: &str,
) -> Option<TypedFunc<P, R>>
where
P: WasmParams,
R: WasmResults,
{
instance.get_typed_func(store, export).ok()
}
fn set_fuel(store: &mut Store<HostState>, fuel: u64) -> Result<()> {
let fuel = if fuel == 0 { u64::MAX } else { fuel };
store
.set_fuel(fuel)
.map_err(|e| PluginError::runtime(NAME, format!("setting fuel: {e}")))
}
fn trap(what: &str, error: &wasmtime::Error) -> PluginError {
PluginError::runtime(NAME, format!("{what}: {error}"))
}
fn config_error(message: impl Into<String>) -> PluginError {
PluginError::config(super::NAME, message.into())
}