feat: add wasmtime host with epoch-bounded WasmProvider

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 15:23:11 +02:00
parent 58f4bd4fdf
commit eeec821af2
4 changed files with 533 additions and 5 deletions
+1
View File
@@ -3,3 +3,4 @@ pub mod fetch;
pub mod http;
pub mod model;
pub mod service;
pub mod wasm;
+182
View File
@@ -0,0 +1,182 @@
use std::path::Path;
use wasmtime::component::{Component, Linker};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
use crate::error::HostError;
use crate::model::{Comment, Entry, FetchedResponse, ParseOutcome};
use crate::service::ProviderHandle;
wasmtime::component::bindgen!({
world: "provider",
path: "../../wit",
});
use exports::whoareyou::provider::lookup::{
LookupError as WitLookupError, Response as WitResponse,
};
/// How many epoch ticks a guest call may run. The epoch thread ticks every
/// 100 ms → 50 ticks ≈ 5 s budget per call.
const EPOCH_DEADLINE_TICKS: u64 = 50;
pub const EPOCH_TICK: std::time::Duration = std::time::Duration::from_millis(100);
pub struct HostState {
ctx: WasiCtx,
table: ResourceTable,
}
impl WasiView for HostState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.ctx,
table: &mut self.table,
}
}
}
pub fn engine() -> Result<Engine, HostError> {
let mut config = Config::new();
config.epoch_interruption(true);
Ok(Engine::new(&config)?)
}
pub fn linker(engine: &Engine) -> Result<Linker<HostState>, HostError> {
let mut linker = Linker::new(engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
Ok(linker)
}
/// Spawn the thread that advances the engine epoch so runaway guest calls
/// trap instead of hanging the service. Call once at startup.
pub fn spawn_epoch_thread(engine: &Engine) {
let engine = engine.clone();
std::thread::spawn(move || {
loop {
std::thread::sleep(EPOCH_TICK);
engine.increment_epoch();
}
});
}
pub struct WasmProvider {
name: String,
version: String,
engine: Engine,
pre: ProviderPre<HostState>,
}
impl WasmProvider {
/// Compile a component from disk and read its metadata once.
/// Fails fast if the component does not satisfy the provider world.
pub fn load(
engine: &Engine,
linker: &Linker<HostState>,
path: &Path,
) -> Result<Self, HostError> {
let component = Component::from_file(engine, path)?;
let pre = ProviderPre::new(linker.instantiate_pre(&component)?)?;
let mut provider = Self {
name: String::new(),
version: String::new(),
engine: engine.clone(),
pre,
};
let mut store = provider.new_store();
let instance = provider.pre.instantiate(&mut store)?;
let info = instance
.whoareyou_provider_lookup()
.call_metadata(&mut store)?;
provider.name = info.name;
provider.version = info.version;
Ok(provider)
}
pub fn version(&self) -> &str {
&self.version
}
fn new_store(&self) -> Store<HostState> {
// No preopens, no env, no inherited stdio — fully sandboxed guest.
let ctx = WasiCtxBuilder::new().build();
let mut store = Store::new(
&self.engine,
HostState {
ctx,
table: ResourceTable::new(),
},
);
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
store
}
}
impl ProviderHandle for WasmProvider {
fn name(&self) -> &str {
&self.name
}
fn requests(&self, number: &str) -> Result<Vec<String>, HostError> {
let mut store = self.new_store();
let instance = self.pre.instantiate(&mut store)?;
let requests = instance
.whoareyou_provider_lookup()
.call_requests(&mut store, number)?;
Ok(requests.into_iter().map(|r| r.url).collect())
}
fn parse(&self, number: &str, responses: &[FetchedResponse]) -> ParseOutcome {
let wit_responses: Vec<WitResponse> = responses
.iter()
.map(|r| WitResponse {
status: r.status,
body: r.body.clone(),
})
.collect();
let mut store = self.new_store();
let result: Result<Result<_, WitLookupError>, wasmtime::Error> = (|| {
let instance = self.pre.instantiate(&mut store)?;
instance
.whoareyou_provider_lookup()
.call_parse(&mut store, number, &wit_responses)
})();
match result {
Ok(Ok(entry)) => ParseOutcome::Ok(Entry {
messages: entry.messages,
history: entry.history,
comments: entry
.comments
.into_iter()
.map(|c| Comment {
timestamp: c.timestamp,
title: c.title,
message: c.message,
})
.collect(),
}),
Ok(Err(WitLookupError::NoData)) => ParseOutcome::NoData,
Ok(Err(WitLookupError::ParseFailed(message))) => ParseOutcome::Failed(message),
// Trap (incl. epoch deadline exceeded) or instantiation failure.
Err(error) => ParseOutcome::Failed(format!("component error: {error}")),
}
}
}