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:
@@ -16,6 +16,7 @@ thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
wasmtime = { version = "45", features = ["component-model"] }
|
||||
wasmtime-wasi = "45"
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1"
|
||||
|
||||
@@ -3,3 +3,4 @@ pub mod fetch;
|
||||
pub mod http;
|
||||
pub mod model;
|
||||
pub mod service;
|
||||
pub mod wasm;
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user