Skip to main content

soma_provider_adapters/
wasm.rs

1//! Wasmtime-backed provider runtime for both the legacy core-Wasm ABI and the
2//! versioned Soma component-model ABI.
3
4use std::{
5    collections::BTreeMap,
6    io::Read,
7    net::{IpAddr, SocketAddr},
8    path::PathBuf,
9    sync::{Arc, Mutex, OnceLock},
10    time::{Duration, Instant},
11};
12
13use async_trait::async_trait;
14use serde_json::Value;
15use soma_provider_core::{
16    Provider, ProviderCall, ProviderCatalog, ProviderError, ProviderOutput, ProviderTool,
17};
18use tokio::time::timeout;
19use wasmtime::{
20    Cache, CacheConfig, Config, Engine, Instance, Module, Store, StoreLimits, StoreLimitsBuilder,
21    component::{Component, Linker},
22};
23
24#[cfg(test)]
25use crate::wasm_memory::public_ip as component_public_ip;
26use crate::{
27    broker_state::BrokerStateStore,
28    sidecar::execution_payload,
29    wasm_limits::WasmRuntimeLimits,
30    wasm_memory::{read_memory, typed, write_memory},
31};
32
33mod artifact;
34mod direct;
35mod host;
36mod runtime_support;
37use artifact::{artifact_digest, compile_artifact, read_artifact};
38pub use direct::{
39    PreparedComponentArtifact, invoke_authorized_component_artifact,
40    invoke_component_artifact_async, invoke_component_artifact_before_async,
41    invoke_prepared_component_artifact_before_async, prepare_component_artifact_before,
42    verify_component_artifact, verify_component_artifact_before,
43};
44use host::{component_diagnostic, component_linker};
45#[cfg(test)]
46use host::{component_state_get, component_state_put};
47use runtime_support::{
48    EpochTicker, WasmArtifactCache, acquire_execution, component_broker,
49    component_forbidden_header, component_metric, component_progress, component_remaining,
50    component_require_scope, resolve_component_hosts,
51};
52
53const MAX_WASM_ARTIFACT_BYTES: usize = 64 * 1024 * 1024;
54const DEFAULT_ARTIFACT_COMPILE_TIMEOUT_SECS: u64 = 30;
55const COMPONENTIZE_ARTIFACT_COMPILE_TIMEOUT_SECS: u64 = 600;
56const COMPONENTIZE_MARKER_NAME: &[u8] = b"soma.componentize-py.v1";
57const VERIFY_MAX_MEMORY_BYTES: usize = 64 * 1024 * 1024;
58const VERIFY_MAX_TABLE_ELEMENTS: usize = 10_000;
59const VERIFY_MAX_INSTANCES: usize = 16;
60const WASMTIME_CACHE_FILE_COUNT_SOFT_LIMIT: u64 = 256;
61const WASMTIME_CACHE_BYTES_SOFT_LIMIT: u64 = 2_147_483_648;
62
63/// Append Soma's deterministic experimental componentize marker custom section.
64pub fn mark_componentize_artifact(bytes: &mut Vec<u8>) -> Result<(), String> {
65    if bytes.len() < 8 || &bytes[..4] != b"\0asm" {
66        return Err("componentize artifact is not a WebAssembly binary".to_owned());
67    }
68    let marker = componentize_marker_section()?;
69    if bytes.ends_with(&marker) {
70        return Ok(());
71    }
72    if bytes.len().saturating_add(marker.len()) > MAX_WASM_ARTIFACT_BYTES {
73        return Err(format!(
74            "marked componentize artifact exceeds {MAX_WASM_ARTIFACT_BYTES} bytes"
75        ));
76    }
77    bytes.extend_from_slice(&marker);
78    Ok(())
79}
80
81/// Return whether a WebAssembly artifact carries Soma's componentize marker.
82#[must_use]
83pub fn is_componentize_artifact(bytes: &[u8]) -> bool {
84    componentize_marker_section()
85        .map(|marker| bytes.ends_with(&marker))
86        .unwrap_or(false)
87}
88
89fn artifact_compile_timeout(bytes: &[u8]) -> Duration {
90    let seconds = if is_componentize_artifact(bytes) {
91        COMPONENTIZE_ARTIFACT_COMPILE_TIMEOUT_SECS
92    } else {
93        DEFAULT_ARTIFACT_COMPILE_TIMEOUT_SECS
94    };
95    Duration::from_secs(seconds)
96}
97
98fn componentize_marker_section() -> Result<Vec<u8>, String> {
99    let name_len = u32::try_from(COMPONENTIZE_MARKER_NAME.len())
100        .map_err(|_| "componentize marker name is too large".to_owned())?;
101    let mut body = Vec::with_capacity(COMPONENTIZE_MARKER_NAME.len() + 5);
102    push_u32_leb(&mut body, name_len);
103    body.extend_from_slice(COMPONENTIZE_MARKER_NAME);
104    let body_len = u32::try_from(body.len())
105        .map_err(|_| "componentize marker section is too large".to_owned())?;
106    let mut section = Vec::with_capacity(body.len() + 6);
107    section.push(0);
108    push_u32_leb(&mut section, body_len);
109    section.extend_from_slice(&body);
110    Ok(section)
111}
112
113fn push_u32_leb(output: &mut Vec<u8>, mut value: u32) {
114    loop {
115        let mut byte = (value & 0x7f) as u8;
116        value >>= 7;
117        if value != 0 {
118            byte |= 0x80;
119        }
120        output.push(byte);
121        if value == 0 {
122            break;
123        }
124    }
125}
126
127#[derive(Clone)]
128pub struct WasmProvider {
129    path: PathBuf,
130    catalog: ProviderCatalog,
131    runtime: Result<PreparedWasmProvider, String>,
132}
133
134#[derive(Clone)]
135struct PreparedWasmProvider {
136    runtime: Arc<WasmRuntime>,
137    artifact: Arc<WasmArtifact>,
138    digest: String,
139    componentize: bool,
140}
141
142impl WasmProvider {
143    pub fn new(path: PathBuf, catalog: ProviderCatalog) -> Self {
144        let runtime = shared_wasm_runtime().and_then(|runtime| {
145            let bytes = read_artifact(&path)?;
146            let digest = artifact_digest(&bytes);
147            let componentize = is_componentize_artifact(&bytes);
148            let artifact =
149                runtime.artifact(&bytes, Instant::now() + artifact_compile_timeout(&bytes))?;
150            Ok(PreparedWasmProvider {
151                runtime,
152                artifact,
153                digest,
154                componentize,
155            })
156        });
157        Self {
158            path,
159            catalog,
160            runtime,
161        }
162    }
163
164    pub fn arc(path: PathBuf, catalog: ProviderCatalog) -> Arc<Self> {
165        Arc::new(Self::new(path, catalog))
166    }
167}
168
169#[async_trait]
170impl Provider for WasmProvider {
171    fn catalog(&self) -> ProviderCatalog {
172        self.catalog.clone()
173    }
174
175    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
176        let tool = self.tool(&call)?.clone();
177        let provider = self.catalog.provider.name.clone();
178        let action = call.action.clone();
179        let source = self.path.display().to_string();
180        let prepared = self.runtime.clone().map_err(|error| {
181            ProviderError::execution(&provider, action.clone(), error)
182                .with_provider_kind("wasm")
183                .with_source(source.clone())
184                .with_phase("runtime-initialization")
185        })?;
186        let current_digest = read_artifact(&self.path)
187            .map(|bytes| artifact_digest(&bytes))
188            .map_err(|error| {
189                ProviderError::execution(&provider, action.clone(), error)
190                    .with_provider_kind("wasm")
191                    .with_source(source.clone())
192                    .with_phase("artifact-verification")
193            })?;
194        if current_digest != prepared.digest {
195            return Err(ProviderError::execution(
196                &provider,
197                action.clone(),
198                "WASM artifact changed after provider activation",
199            )
200            .with_provider_kind("wasm")
201            .with_source(source)
202            .with_phase("artifact-verification"));
203        }
204        let capabilities = self.catalog.capabilities.clone();
205        let context = call.context.clone();
206        let input = execution_payload(&call).map_err(|error| {
207            ProviderError::execution(&provider, call.action.clone(), error)
208                .with_provider_kind("wasm")
209                .with_source(source.clone())
210                .with_phase("input-serialization")
211        })?;
212        let limits =
213            WasmRuntimeLimits::from_tool(&tool).with_componentize_minimums(prepared.componentize);
214        if input.len() > limits.max_input_bytes {
215            return Err(ProviderError::validation(
216                provider,
217                call.action,
218                "wasm_input_too_large",
219                format!("WASM input exceeds {} bytes", limits.max_input_bytes),
220            )
221            .with_provider_kind("wasm")
222            .with_source(source)
223            .with_phase("input-validation"));
224        }
225
226        let timeout_ms = limits.timeout_ms;
227        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
228        let resolved_hosts = resolve_component_hosts(&capabilities, deadline)
229            .await
230            .map_err(|error| {
231                ProviderError::execution(&provider, action.clone(), error)
232                    .with_provider_kind("wasm")
233                    .with_source(source.clone())
234                    .with_phase("network-resolution")
235            })?;
236        let task = tokio::task::spawn_blocking(move || {
237            prepared.runtime.run_artifact(
238                prepared.artifact,
239                WasmInvocation {
240                    input: &input,
241                    limits,
242                    capabilities: &capabilities,
243                    context: &context,
244                    resolved_hosts,
245                    deadline,
246                },
247            )
248        });
249        let remaining = deadline
250            .checked_duration_since(Instant::now())
251            .unwrap_or(Duration::ZERO);
252        let task_result = timeout(remaining, task)
253            .await
254            .map_err(|_| {
255                ProviderError::new(
256                    "wasm_provider_timeout",
257                    &provider,
258                    Some(action.clone()),
259                    format!("WASM provider exceeded {timeout_ms}ms timeout"),
260                    "Increase tool.limits.timeout_ms or fix the WASM provider.",
261                )
262                .with_provider_kind("wasm")
263                .with_source(source.clone())
264                .with_phase("execution")
265            })?
266            .map_err(|error| {
267                ProviderError::execution(&provider, action.clone(), error)
268                    .with_provider_kind("wasm")
269                    .with_source(source.clone())
270                    .with_phase("execution")
271            })?;
272        if Instant::now() >= deadline {
273            return Err(ProviderError::new(
274                "wasm_provider_timeout",
275                &provider,
276                Some(action.clone()),
277                format!("WASM provider exceeded {timeout_ms}ms timeout"),
278                "Increase tool.limits.timeout_ms or fix the WASM provider.",
279            )
280            .with_provider_kind("wasm")
281            .with_source(source.clone())
282            .with_phase("execution"));
283        }
284        let output = task_result.map_err(|error| {
285            ProviderError::execution(&provider, action.clone(), error)
286                .with_provider_kind("wasm")
287                .with_source(source.clone())
288                .with_phase("execution")
289        })?;
290
291        let value = serde_json::from_slice(&output).map_err(|error| {
292            ProviderError::validation(
293                &provider,
294                &action,
295                "wasm_invalid_json_output",
296                error.to_string(),
297            )
298            .with_provider_kind("wasm")
299            .with_source(source)
300            .with_phase("output-validation")
301        })?;
302        Ok(ProviderOutput::json(value))
303    }
304
305    fn runtime_status(&self) -> Option<Value> {
306        Some(match &self.runtime {
307            Ok(prepared) => serde_json::json!({
308                "kind": "wasm",
309                "ready": true,
310                "artifact": self.path,
311                "artifact_sha256": prepared.digest,
312            }),
313            Err(error) => serde_json::json!({
314                "kind": "wasm",
315                "ready": false,
316                "artifact": self.path,
317                "error": error,
318            }),
319        })
320    }
321}
322
323impl WasmProvider {
324    fn tool(&self, call: &ProviderCall) -> Result<&ProviderTool, ProviderError> {
325        self.catalog
326            .tools
327            .iter()
328            .find(|tool| tool.name == call.action)
329            .ok_or_else(|| {
330                ProviderError::validation(
331                    &self.catalog.provider.name,
332                    &call.action,
333                    "unknown_wasm_action",
334                    format!("WASM provider has no action `{}`", call.action),
335                )
336            })
337    }
338}
339
340enum WasmArtifact {
341    Core(Module),
342    Component(Component),
343}
344
345pub(super) struct WasmStoreState {
346    limits: StoreLimits,
347    capabilities: soma_provider_core::HostCapabilities,
348    state: Result<Arc<BrokerStateStore>, String>,
349    context: soma_provider_core::ProviderInvocationContext,
350    resolved_hosts: BTreeMap<String, Vec<IpAddr>>,
351    deadline: Instant,
352    wasi: wasmtime_wasi::WasiCtx,
353    table: wasmtime::component::ResourceTable,
354}
355
356impl wasmtime_wasi::WasiView for WasmStoreState {
357    fn ctx(&mut self) -> wasmtime_wasi::WasiCtxView<'_> {
358        wasmtime_wasi::WasiCtxView {
359            ctx: &mut self.wasi,
360            table: &mut self.table,
361        }
362    }
363}
364
365struct WasmRuntime {
366    engine: Engine,
367    cache: Mutex<WasmArtifactCache>,
368    state: Result<Arc<BrokerStateStore>, String>,
369    _ticker: EpochTicker,
370}
371
372struct WasmInvocation<'a> {
373    input: &'a [u8],
374    limits: WasmRuntimeLimits,
375    capabilities: &'a soma_provider_core::HostCapabilities,
376    context: &'a soma_provider_core::ProviderInvocationContext,
377    resolved_hosts: BTreeMap<String, Vec<IpAddr>>,
378    deadline: Instant,
379}
380
381fn shared_wasm_runtime() -> Result<Arc<WasmRuntime>, String> {
382    static RUNTIME: OnceLock<Result<Arc<WasmRuntime>, String>> = OnceLock::new();
383    RUNTIME
384        .get_or_init(|| WasmRuntime::new().map(Arc::new))
385        .clone()
386}
387
388fn wasmtime_cache_config() -> CacheConfig {
389    let mut config = CacheConfig::new();
390    config
391        .with_file_count_soft_limit(WASMTIME_CACHE_FILE_COUNT_SOFT_LIMIT)
392        .with_files_total_size_soft_limit(WASMTIME_CACHE_BYTES_SOFT_LIMIT)
393        .with_file_count_limit_percent_if_deleting(75)
394        .with_files_total_size_limit_percent_if_deleting(75);
395    config
396}
397
398fn wasmtime_cache() -> Result<Cache, String> {
399    Cache::new(wasmtime_cache_config())
400        .map_err(|error| format!("failed to initialize Wasmtime cache: {error}"))
401}
402
403impl WasmRuntime {
404    fn new() -> Result<Self, String> {
405        let mut config = Config::new();
406        config.consume_fuel(true);
407        config.epoch_interruption(true);
408        config.wasm_component_model(true);
409        config.cache(Some(wasmtime_cache()?));
410        let engine = Engine::new(&config).map_err(|error| error.to_string())?;
411        let ticker = EpochTicker::start(engine.clone())?;
412        Ok(Self {
413            engine,
414            cache: Mutex::new(WasmArtifactCache::default()),
415            state: BrokerStateStore::configured(),
416            _ticker: ticker,
417        })
418    }
419
420    fn artifact(&self, bytes: &[u8], deadline: Instant) -> Result<Arc<WasmArtifact>, String> {
421        let digest = artifact_digest(bytes);
422        let cell = self
423            .cache
424            .lock()
425            .map_err(|_| "WASM cache lock is poisoned".to_owned())?
426            .cell(digest, bytes.len().saturating_mul(8));
427        let result = cell.get_or_compile(deadline, || compile_artifact(self, bytes, deadline));
428        self.cache
429            .lock()
430            .map_err(|_| "WASM cache lock is poisoned".to_owned())?
431            .prune();
432        result
433    }
434
435    fn verify_prepared_component(
436        &self,
437        artifact: &Arc<WasmArtifact>,
438        componentize: bool,
439        deadline: Instant,
440    ) -> Result<(), String> {
441        let limits = WasmRuntimeLimits {
442            timeout_ms: 1_000,
443            max_input_bytes: 0,
444            max_output_bytes: 0,
445            fuel: 100_000,
446            max_memory_bytes: VERIFY_MAX_MEMORY_BYTES,
447            max_table_elements: VERIFY_MAX_TABLE_ELEMENTS,
448            max_instances: VERIFY_MAX_INSTANCES,
449        }
450        .with_componentize_minimums(componentize);
451        let _execution = acquire_execution(deadline, limits.max_memory_bytes)?;
452        let WasmArtifact::Component(component) = artifact.as_ref() else {
453            return Err("artifact is core Wasm, not a component".to_owned());
454        };
455        let mut store = self.store(
456            limits,
457            &soma_provider_core::HostCapabilities::default(),
458            &soma_provider_core::ProviderInvocationContext {
459                request_id: "component-verification".to_owned(),
460                actor_id: Some("soma-verifier".to_owned()),
461                actor_scopes: vec!["soma:read".to_owned()],
462                ..Default::default()
463            },
464            BTreeMap::new(),
465            deadline,
466        )?;
467        let linker = component_linker(&self.engine)?;
468        let instance = linker
469            .instantiate(&mut store, component)
470            .map_err(|error| error.to_string())?;
471        instance
472            .get_typed_func::<(String,), (Result<String, String>,)>(&mut store, "invoke")
473            .map(|_| ())
474            .map_err(|error| format!("component does not implement soma:provider@1.0.0: {error}"))
475    }
476
477    #[cfg(test)]
478    fn verify_component(&self, bytes: &[u8], deadline: Instant) -> Result<(), String> {
479        let componentize = is_componentize_artifact(bytes);
480        let artifact = self.artifact(bytes, deadline)?;
481        self.verify_prepared_component(&artifact, componentize, deadline)
482    }
483
484    #[cfg(test)]
485    fn run(
486        &self,
487        path: &std::path::Path,
488        invocation: WasmInvocation<'_>,
489    ) -> Result<Vec<u8>, String> {
490        let bytes = read_artifact(path)?;
491        let artifact = self.artifact(&bytes, invocation.deadline)?;
492        self.run_artifact(artifact, invocation)
493    }
494
495    fn run_artifact(
496        &self,
497        artifact: Arc<WasmArtifact>,
498        invocation: WasmInvocation<'_>,
499    ) -> Result<Vec<u8>, String> {
500        let WasmInvocation {
501            input,
502            limits,
503            capabilities,
504            context,
505            resolved_hosts,
506            deadline,
507        } = invocation;
508        let _execution = acquire_execution(deadline, limits.max_memory_bytes)?;
509        let mut store = self.store(limits, capabilities, context, resolved_hosts, deadline)?;
510        match artifact.as_ref() {
511            WasmArtifact::Core(module) => run_core_wasm(&mut store, module, input, limits),
512            WasmArtifact::Component(component) => {
513                run_component_wasm(&self.engine, &mut store, component, input, limits)
514            }
515        }
516    }
517
518    fn store(
519        &self,
520        limits: WasmRuntimeLimits,
521        capabilities: &soma_provider_core::HostCapabilities,
522        context: &soma_provider_core::ProviderInvocationContext,
523        resolved_hosts: BTreeMap<String, Vec<IpAddr>>,
524        deadline: Instant,
525    ) -> Result<Store<WasmStoreState>, String> {
526        let store_limits = StoreLimitsBuilder::new()
527            .memory_size(limits.max_memory_bytes)
528            .table_elements(limits.max_table_elements)
529            .instances(limits.max_instances)
530            .memories(4)
531            .tables(4)
532            .build();
533        let mut store = Store::new(
534            &self.engine,
535            WasmStoreState {
536                limits: store_limits,
537                capabilities: capabilities.clone(),
538                state: self.state.clone(),
539                context: context.clone(),
540                resolved_hosts,
541                deadline,
542                wasi: {
543                    let mut builder = wasmtime_wasi::WasiCtxBuilder::new();
544                    builder.allow_blocking_current_thread(true);
545                    builder.build()
546                },
547                table: wasmtime::component::ResourceTable::new(),
548            },
549        );
550        store.limiter(|state| &mut state.limits);
551        store
552            .set_fuel(limits.fuel)
553            .map_err(|error| error.to_string())?;
554        let remaining_ms = deadline
555            .checked_duration_since(Instant::now())
556            .unwrap_or(Duration::ZERO)
557            .as_millis()
558            .min(u128::from(u64::MAX)) as u64;
559        store.set_epoch_deadline(remaining_ms.div_ceil(10).max(1));
560        store.epoch_deadline_trap();
561        Ok(store)
562    }
563}
564
565fn run_core_wasm(
566    store: &mut Store<WasmStoreState>,
567    module: &Module,
568    input: &[u8],
569    limits: WasmRuntimeLimits,
570) -> Result<Vec<u8>, String> {
571    let instance = Instance::new(&mut *store, module, &[]).map_err(|error| error.to_string())?;
572    let memory = instance
573        .get_memory(&mut *store, "memory")
574        .ok_or_else(|| "WASM provider must export memory".to_owned())?;
575    let input_alloc = typed::<i32, i32>(&instance, &mut *store, "soma_input_alloc")?;
576    let input_ptr_fn = typed::<(), i32>(&instance, &mut *store, "soma_input_ptr")?;
577    let call_fn = typed::<(), i32>(&instance, &mut *store, "soma_call")?;
578    let output_ptr_fn = typed::<(), i32>(&instance, &mut *store, "soma_output_ptr")?;
579    let output_len_fn = typed::<(), i32>(&instance, &mut *store, "soma_output_len")?;
580
581    let ptr = input_alloc
582        .call(&mut *store, input.len() as i32)
583        .map_err(|error| error.to_string())? as usize;
584    let input_ptr = input_ptr_fn
585        .call(&mut *store, ())
586        .map_err(|error| error.to_string())? as usize;
587    if ptr != input_ptr {
588        return Err("WASM provider input pointer mismatch".to_owned());
589    }
590    write_memory(&memory, &mut *store, ptr, input)?;
591    let status = call_fn
592        .call(&mut *store, ())
593        .map_err(|error| error.to_string())?;
594    if status != 0 {
595        return Err(format!("WASM provider returned non-zero status {status}"));
596    }
597    let output_ptr = output_ptr_fn
598        .call(&mut *store, ())
599        .map_err(|error| error.to_string())? as usize;
600    let output_len = output_len_fn
601        .call(&mut *store, ())
602        .map_err(|error| error.to_string())? as usize;
603    if output_len > limits.max_output_bytes {
604        return Err(format!(
605            "WASM provider output exceeds {} bytes",
606            limits.max_output_bytes
607        ));
608    }
609    read_memory(&memory, &mut *store, output_ptr, output_len)
610}
611
612fn run_component_wasm(
613    engine: &Engine,
614    store: &mut Store<WasmStoreState>,
615    component: &Component,
616    input: &[u8],
617    limits: WasmRuntimeLimits,
618) -> Result<Vec<u8>, String> {
619    let linker = component_linker(engine)?;
620    let instance = linker
621        .instantiate(&mut *store, component)
622        .map_err(|error| error.to_string())?;
623    let invoke = instance
624        .get_typed_func::<(String,), (Result<String, String>,)>(&mut *store, "invoke")
625        .map_err(|error| error.to_string())?;
626    let input = String::from_utf8(input.to_vec())
627        .map_err(|_| "component input must be UTF-8 JSON".to_owned())?;
628    let (result,) = invoke
629        .call(&mut *store, (input,))
630        .map_err(|error| format!("{error:#}"))?;
631    let output = result.map_err(|error| format!("component provider failed: {error}"))?;
632    if output.len() > limits.max_output_bytes {
633        return Err(format!(
634            "WASM provider output exceeds {} bytes",
635            limits.max_output_bytes
636        ));
637    }
638    Ok(output.into_bytes())
639}
640
641#[cfg(test)]
642#[path = "wasm_tests.rs"]
643mod tests;