Skip to main content

outl_exec/wasm/
module.rs

1//! `WasmModule` — the adapter that turns a WASI `.wasm` binary into a
2//! [`crate::Runtime`].
3//!
4//! Use this to host any language whose interpreter ships as a WASI
5//! module: pass the bytes, point the language tag, done. The same
6//! adapter will eventually back Lisp/JS/Python/Lua once their WASM
7//! builds are stable.
8//!
9//! Contract for the hosted module:
10//!
11//! - It's a `_start`-style WASI command (`wasmtime <file>` runs it).
12//! - Source is delivered on stdin.
13//! - Output goes to stdout, diagnostics to stderr.
14//! - Exit code 0 = success; non-zero = user-script error.
15//!
16//! That contract makes every hosted interpreter testable on the host
17//! with `wasmtime` directly, without our crate in the loop.
18
19use std::sync::{Arc, Mutex};
20use std::time::Instant;
21
22use wasmtime::{Engine, Linker, Module, Store};
23use wasmtime_wasi::p1::WasiP1Ctx;
24use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
25use wasmtime_wasi::WasiCtxBuilder;
26
27use crate::runtime::{ExecContext, ExecError, ExecOutput, ExitStatus, OutputFormat, Runtime};
28use crate::wasm::engine::SandboxLimits;
29
30/// A WASM-hosted language runtime.
31///
32/// Built from a fully-loaded `wasmtime::Module`. Cloning is cheap —
33/// the underlying engine/module are `Arc`-shared internally.
34pub struct WasmModule {
35    language: &'static str,
36    engine: Engine,
37    module: Module,
38    limits: SandboxLimits,
39}
40
41impl WasmModule {
42    /// Construct from raw WASI module bytes (already validated by
43    /// wasmtime on load).
44    pub fn from_bytes(
45        language: &'static str,
46        engine: &Engine,
47        wasm: &[u8],
48    ) -> Result<Self, ExecError> {
49        let module = Module::from_binary(engine, wasm)
50            .map_err(|e| ExecError::Sandbox(format!("load wasm: {e}")))?;
51        Ok(Self {
52            language,
53            engine: engine.clone(),
54            module,
55            limits: SandboxLimits::default(),
56        })
57    }
58
59    /// Override sandbox limits (fuel + memory cap). The default is
60    /// usually enough; tighten for long-running daemons, loosen for
61    /// batch jobs.
62    pub fn with_limits(mut self, limits: SandboxLimits) -> Self {
63        self.limits = limits;
64        self
65    }
66}
67
68impl Runtime for WasmModule {
69    fn language(&self) -> &'static str {
70        self.language
71    }
72
73    fn execute(&self, source: &str, ctx: &ExecContext) -> Result<ExecOutput, ExecError> {
74        let start = Instant::now();
75
76        // Pipes: stdin = source bytes, stdout/stderr = in-memory
77        // buffers we read back after the run.
78        let stdin_pipe = MemoryInputPipe::new(source.as_bytes().to_vec());
79        let stdout_pipe = MemoryOutputPipe::new(64 * 1024);
80        let stderr_pipe = MemoryOutputPipe::new(64 * 1024);
81
82        let stdout_read = stdout_pipe.clone();
83        let stderr_read = stderr_pipe.clone();
84
85        let wasi = WasiCtxBuilder::new()
86            .stdin(stdin_pipe)
87            .stdout(stdout_pipe)
88            .stderr(stderr_pipe)
89            // No env, no preopens, no sockets. The script gets to
90            // read its stdin and write its stdout. Nothing else.
91            .build_p1();
92
93        let mut store = Store::new(&self.engine, wasi);
94        store
95            .set_fuel(self.limits.fuel)
96            .map_err(|e| ExecError::Sandbox(format!("set fuel: {e}")))?;
97
98        // Epoch interruption: bump the engine epoch from a worker
99        // thread after `ctx.timeout`. The next wasm instruction will
100        // trap and we'll convert it to ExecError::Timeout.
101        store.set_epoch_deadline(1);
102        let timeout_engine = self.engine.clone();
103        let cancel_guard = Arc::new(Mutex::new(false));
104        let cancel_for_thread = cancel_guard.clone();
105        let timeout = ctx.timeout;
106        std::thread::Builder::new()
107            .name("outl-wasm-watchdog".into())
108            .spawn(move || {
109                std::thread::sleep(timeout);
110                if !*cancel_for_thread.lock().unwrap() {
111                    timeout_engine.increment_epoch();
112                }
113            })
114            .map_err(|e| ExecError::Sandbox(format!("spawn watchdog: {e}")))?;
115
116        let mut linker: Linker<WasiP1Ctx> = Linker::new(&self.engine);
117        wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |s| s)
118            .map_err(|e| ExecError::Sandbox(format!("link wasi: {e}")))?;
119
120        let instance = linker
121            .instantiate(&mut store, &self.module)
122            .map_err(|e| ExecError::Sandbox(format!("instantiate: {e}")))?;
123
124        let start_func = instance
125            .get_typed_func::<(), ()>(&mut store, "_start")
126            .map_err(|e| ExecError::Sandbox(format!("module missing `_start`: {e}")))?;
127        let call_result = start_func.call(&mut store, ());
128
129        // Tell the watchdog we're done so it doesn't kick a future run.
130        *cancel_guard.lock().unwrap() = true;
131
132        let stdout = pipe_to_string(&stdout_read);
133        let stderr = pipe_to_string(&stderr_read);
134        let duration = start.elapsed();
135
136        match call_result {
137            Ok(()) => Ok(ExecOutput {
138                stdout,
139                stderr,
140                duration,
141                exit: ExitStatus::Ok,
142                format: OutputFormat::Text,
143            }),
144            Err(e) => {
145                // Classify the trap.
146                let msg = format!("{e:?}");
147                if msg.contains("out of fuel") || msg.contains("Interrupt") {
148                    return Err(ExecError::Timeout(timeout));
149                }
150                // WASI `_start` exits via a special trap that carries
151                // the exit code; pull it out if present.
152                let exit = if let Some(exit_code) =
153                    e.downcast_ref::<wasmtime_wasi::I32Exit>().map(|i| i.0)
154                {
155                    if exit_code == 0 {
156                        ExitStatus::Ok
157                    } else {
158                        ExitStatus::NonZero(exit_code)
159                    }
160                } else {
161                    ExitStatus::Trap(format!("{e}"))
162                };
163                Ok(ExecOutput {
164                    stdout,
165                    stderr,
166                    duration,
167                    exit,
168                    format: OutputFormat::Text,
169                })
170            }
171        }
172    }
173}
174
175fn pipe_to_string(p: &MemoryOutputPipe) -> String {
176    String::from_utf8_lossy(p.contents().as_ref()).into_owned()
177}