Skip to main content

oxide_k/
wasm_exec.rs

1//! # `wasmtime`-backed Wasm executor
2//!
3//! Available under the `wasmtime-runtime` feature flag.
4//!
5//! [`WasmExecutor`] loads a `.wasm` artefact off disk (typically a
6//! `oxide-compress` build), instantiates it inside a fresh `wasmtime::Store`,
7//! and exposes the exported functions as plain async callables on the
8//! kernel.
9//!
10//! Two convenience entry points ship today:
11//!
12//! * [`WasmExecutor::call_i32_to_i32`] — invokes an exported
13//!   `fn(x: i32) -> i32` such as the `oxide-compress` `add_one` smoke test.
14//! * [`WasmExecutor::list_exports`] — enumerates exported function names so
15//!   higher-level dispatchers can route calls dynamically.
16//!
17//! Full host-ABI (JSON-string in / out via linear memory) is intentionally
18//! deferred — the goal of this module is to prove the runtime works and
19//! provide a stable seam for future expansion, not to ship a full WIT
20//! binding generator.
21
22#![cfg(feature = "wasmtime-runtime")]
23
24use std::path::Path;
25use std::sync::Mutex;
26
27use wasmtime::{Engine, Instance, Module, Store, TypedFunc};
28
29use crate::error::{KernelError, Result};
30
31/// Wraps a wasmtime [`Module`] + [`Store`] + [`Instance`] under a single
32/// async-friendly handle.
33pub struct WasmExecutor {
34    engine: Engine,
35    module: Module,
36    inner: Mutex<Instance>,
37    store: Mutex<Store<()>>,
38}
39
40impl WasmExecutor {
41    /// Load `.wasm` bytes from disk.
42    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
43        let bytes = std::fs::read(path.as_ref()).map_err(|e| {
44            KernelError::Other(anyhow::anyhow!(
45                "failed to read wasm artefact {}: {e}",
46                path.as_ref().display()
47            ))
48        })?;
49        Self::from_bytes(&bytes)
50    }
51
52    /// Build an executor from raw `.wasm` bytes.
53    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
54        let engine = Engine::default();
55        let module = Module::new(&engine, bytes).map_err(to_kernel)?;
56        let mut store: Store<()> = Store::new(&engine, ());
57        let instance = Instance::new(&mut store, &module, &[]).map_err(to_kernel)?;
58        Ok(Self {
59            engine,
60            module,
61            inner: Mutex::new(instance),
62            store: Mutex::new(store),
63        })
64    }
65
66    /// Enumerate exported function names.
67    pub fn list_exports(&self) -> Vec<String> {
68        self.module
69            .exports()
70            .filter_map(|e| match e.ty() {
71                wasmtime::ExternType::Func(_) => Some(e.name().to_string()),
72                _ => None,
73            })
74            .collect()
75    }
76
77    /// Invoke `name(x: i32) -> i32`.
78    pub fn call_i32_to_i32(&self, name: &str, arg: i32) -> Result<i32> {
79        let mut store = self.store.lock().unwrap();
80        let instance = self.inner.lock().unwrap();
81        let func: TypedFunc<i32, i32> = instance
82            .get_typed_func::<i32, i32>(&mut *store, name)
83            .map_err(|e| {
84                KernelError::Other(anyhow::anyhow!(
85                    "wasm export `{name}` is not (i32) -> i32: {e}"
86                ))
87            })?;
88        func.call(&mut *store, arg).map_err(to_kernel)
89    }
90
91    /// Call a WASM guest method with JSON input and receive JSON output.
92    ///
93    /// The guest must export three symbols:
94    /// - `alloc(size: i32) -> i32` — allocate `size` bytes, return pointer
95    /// - `free(ptr: i32, size: i32)` — free a previous allocation
96    /// - `oxide_invoke(method_ptr: i32, method_len: i32, input_ptr: i32, input_len: i32) -> i64`
97    ///   — dispatch; the `i64` return packs output `ptr` (high 32 bits) and
98    ///   `len` (low 32 bits).
99    ///
100    /// # Errors
101    ///
102    /// Returns [`KernelError::Other`] if any WASM call fails, memory is out of
103    /// bounds, or the output bytes are not valid UTF-8 / JSON.
104    pub fn call_json(&self, method: &str, input: &serde_json::Value) -> Result<serde_json::Value> {
105        let mut store = self.store.lock().unwrap();
106        let instance = self.inner.lock().unwrap();
107
108        // Resolve guest functions.
109        let alloc: TypedFunc<i32, i32> = instance
110            .get_typed_func::<i32, i32>(&mut *store, "alloc")
111            .map_err(|e| KernelError::Other(anyhow::anyhow!("alloc export: {e}")))?;
112        let free: TypedFunc<(i32, i32), ()> = instance
113            .get_typed_func::<(i32, i32), ()>(&mut *store, "free")
114            .map_err(|e| KernelError::Other(anyhow::anyhow!("free export: {e}")))?;
115        let invoke: TypedFunc<(i32, i32, i32, i32), i64> = instance
116            .get_typed_func::<(i32, i32, i32, i32), i64>(&mut *store, "oxide_invoke")
117            .map_err(|e| KernelError::Other(anyhow::anyhow!("oxide_invoke export: {e}")))?;
118
119        let memory = instance
120            .get_memory(&mut *store, "memory")
121            .ok_or_else(|| KernelError::Other(anyhow::anyhow!("no `memory` export")))?;
122
123        // Serialize method name and input JSON.
124        let method_bytes = method.as_bytes().to_vec();
125        let input_bytes = serde_json::to_vec(input).map_err(|e| KernelError::Other(e.into()))?;
126
127        // Write method bytes into guest memory.
128        let method_len = method_bytes.len() as i32;
129        let method_ptr = alloc
130            .call(&mut *store, method_len)
131            .map_err(|e| KernelError::Other(anyhow::anyhow!("alloc method: {e}")))?;
132        {
133            let mem = memory.data_mut(&mut *store);
134            let s = method_ptr as usize;
135            if s + method_bytes.len() > mem.len() {
136                return Err(KernelError::Other(anyhow::anyhow!("method OOB")));
137            }
138            mem[s..s + method_bytes.len()].copy_from_slice(&method_bytes);
139        }
140
141        // Write input bytes into guest memory.
142        let input_len = input_bytes.len() as i32;
143        let input_ptr = alloc
144            .call(&mut *store, input_len)
145            .map_err(|e| KernelError::Other(anyhow::anyhow!("alloc input: {e}")))?;
146        {
147            let mem = memory.data_mut(&mut *store);
148            let s = input_ptr as usize;
149            if s + input_bytes.len() > mem.len() {
150                return Err(KernelError::Other(anyhow::anyhow!("input OOB")));
151            }
152            mem[s..s + input_bytes.len()].copy_from_slice(&input_bytes);
153        }
154
155        // Call oxide_invoke.
156        let result = invoke
157            .call(&mut *store, (method_ptr, method_len, input_ptr, input_len))
158            .map_err(|e| KernelError::Other(anyhow::anyhow!("oxide_invoke: {e}")))?;
159
160        // Free input buffers.
161        let _ = free.call(&mut *store, (method_ptr, method_len));
162        let _ = free.call(&mut *store, (input_ptr, input_len));
163
164        // Decode output: high 32 bits = ptr, low 32 bits = len.
165        let out_ptr = ((result >> 32) & 0xFFFF_FFFF) as usize;
166        let out_len = (result & 0xFFFF_FFFF) as usize;
167
168        let output_bytes = {
169            let mem = memory.data(&*store);
170            if out_ptr + out_len > mem.len() {
171                return Err(KernelError::Other(anyhow::anyhow!("output OOB")));
172            }
173            mem[out_ptr..out_ptr + out_len].to_vec()
174        };
175
176        // Free output buffer.
177        let _ = free.call(&mut *store, (out_ptr as i32, out_len as i32));
178
179        let output = serde_json::from_slice(&output_bytes)
180            .map_err(|e| KernelError::Other(anyhow::anyhow!("output JSON: {e}")))?;
181        Ok(output)
182    }
183
184    /// Underlying wasmtime engine, for callers that want to share it with
185    /// other modules.
186    pub fn engine(&self) -> &Engine {
187        &self.engine
188    }
189}
190
191fn to_kernel(err: impl std::fmt::Display) -> KernelError {
192    KernelError::Other(anyhow::anyhow!("wasmtime: {err}"))
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    /// Hand-rolled WAT for `(i32) -> i32` add-one. Avoids needing a separate
200    /// pre-built `.wasm` fixture.
201    fn add_one_wat() -> Vec<u8> {
202        wat::parse_str(
203            r#"
204            (module
205              (func (export "add_one") (param i32) (result i32)
206                local.get 0
207                i32.const 1
208                i32.add))
209            "#,
210        )
211        .expect("valid wat")
212    }
213
214    #[test]
215    fn executor_can_run_pure_i32_function() {
216        let bytes = add_one_wat();
217        let exec = WasmExecutor::from_bytes(&bytes).unwrap();
218        assert_eq!(exec.list_exports(), vec!["add_one".to_string()]);
219        assert_eq!(exec.call_i32_to_i32("add_one", 41).unwrap(), 42);
220    }
221
222    #[test]
223    fn missing_export_errors() {
224        let exec = WasmExecutor::from_bytes(&add_one_wat()).unwrap();
225        let err = exec.call_i32_to_i32("nope", 1).unwrap_err();
226        assert!(format!("{err}").contains("nope"));
227    }
228
229    /// WAT module that implements the full oxide_invoke ABI.
230    /// It echoes `{"echo": <input>}` back to the host via linear memory.
231    fn echo_abi_wat() -> Vec<u8> {
232        // The module uses a static 64 KB memory page.
233        // alloc: returns a fixed output region starting at offset 4096.
234        // We use a simple bump strategy: alloc always gives sequential regions.
235        // For this test the payloads are tiny so there's no collision.
236        wat::parse_str(
237            r#"
238            (module
239              (memory (export "memory") 2)
240
241              ;; Bump allocator: ptr stored at byte 0 (i32), initial = 256
242              (func (export "alloc") (param $size i32) (result i32)
243                (local $ptr i32)
244                ;; read current bump ptr (stored at address 0)
245                (local.set $ptr (i32.load (i32.const 0)))
246                ;; if zero, initialise to 256
247                (if (i32.eqz (local.get $ptr))
248                  (then (local.set $ptr (i32.const 256)))
249                )
250                ;; store advanced ptr
251                (i32.store (i32.const 0) (i32.add (local.get $ptr) (local.get $size)))
252                ;; return old ptr
253                (local.get $ptr)
254              )
255
256              ;; free is a no-op in this bump allocator
257              (func (export "free") (param $ptr i32) (param $size i32))
258
259              ;; oxide_invoke: writes {"ok":true} into memory and returns ptr<<32|len
260              (func (export "oxide_invoke")
261                    (param $mp i32) (param $ml i32)
262                    (param $ip i32) (param $il i32)
263                    (result i64)
264                (local $out_ptr i32)
265                (local $payload_len i32)
266                ;; static output: write `{"ok":true}` at address 8192
267                ;; 0x7b = '{', 0x22 = '"', 0x6f='o',0x6b='k',0x22='"',0x3a=':',
268                ;; 0x74='t',0x72='r',0x75='u',0x65='e',0x7d='}'  = 11 bytes
269                (i32.store8 (i32.const 8192) (i32.const 123))  ;; {
270                (i32.store8 (i32.const 8193) (i32.const 34))   ;; "
271                (i32.store8 (i32.const 8194) (i32.const 111))  ;; o
272                (i32.store8 (i32.const 8195) (i32.const 107))  ;; k
273                (i32.store8 (i32.const 8196) (i32.const 34))   ;; "
274                (i32.store8 (i32.const 8197) (i32.const 58))   ;; :
275                (i32.store8 (i32.const 8198) (i32.const 116))  ;; t
276                (i32.store8 (i32.const 8199) (i32.const 114))  ;; r
277                (i32.store8 (i32.const 8200) (i32.const 117))  ;; u
278                (i32.store8 (i32.const 8201) (i32.const 101))  ;; e
279                (i32.store8 (i32.const 8202) (i32.const 125))  ;; }
280                (local.set $out_ptr (i32.const 8192))
281                (local.set $payload_len (i32.const 11))
282                ;; return (ptr << 32) | len  as i64
283                (i64.or
284                  (i64.shl (i64.extend_i32_u (local.get $out_ptr)) (i64.const 32))
285                  (i64.extend_i32_u (local.get $payload_len))
286                )
287              )
288            )
289            "#,
290        )
291        .expect("valid echo ABI wat")
292    }
293
294    #[test]
295    fn call_json_round_trips_via_abi() {
296        let bytes = echo_abi_wat();
297        let exec = WasmExecutor::from_bytes(&bytes).unwrap();
298        let result = exec
299            .call_json("echo", &serde_json::json!({"hello": "world"}))
300            .unwrap();
301        assert_eq!(result["ok"], serde_json::Value::Bool(true));
302    }
303}