kobako 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ //! Host-side dispatch for the `__kobako_rpc_call` import.
2
+ //!
3
+ //! When the guest invokes the wasm import declared in
4
+ //! `wasm/kobako-wasm/src/abi.rs`, wasmtime calls back into the host
5
+ //! through the closure built in [`super::instance::build_instance`].
6
+ //! That closure delegates here. The dispatcher (SPEC.md B-12 / B-13):
7
+ //!
8
+ //! 1. Reads the Request bytes from guest linear memory.
9
+ //! 2. Hands them to the Ruby-side `Kobako::Registry` and recovers
10
+ //! Response bytes.
11
+ //! 3. Allocates a guest buffer via `__kobako_alloc(len)` invoked
12
+ //! through `Caller::get_export`.
13
+ //! 4. Writes the Response bytes into the guest buffer.
14
+ //! 5. Returns packed `(ptr<<32)|len` for the guest to decode.
15
+ //!
16
+ //! Returns 0 on any step failure. `Kobako::Sandbox` always installs a
17
+ //! Registry before invoking the guest, so reaching the dispatcher with
18
+ //! no Registry bound is itself a wire-layer fault; the guest maps a 0
19
+ //! return to a trap. Failures during normal dispatch surface as
20
+ //! Response.err envelopes from the Registry itself — they never reach
21
+ //! this 0-return path.
22
+
23
+ use magnus::value::{Opaque, ReprValue};
24
+ use magnus::{Error as MagnusError, RString, Ruby, Value};
25
+ use wasmtime::{Caller, Extern};
26
+
27
+ use super::host_state::HostState;
28
+
29
+ /// Drive a single `__kobako_rpc_call` invocation end-to-end. Entry point
30
+ /// from the wasmtime closure built in [`super::instance::build_instance`].
31
+ pub(crate) fn dispatch_rpc(caller: &mut Caller<'_, HostState>, req_ptr: i32, req_len: i32) -> i64 {
32
+ let req_bytes = match read_caller_memory(caller, req_ptr, req_len) {
33
+ Some(b) => b,
34
+ None => return 0,
35
+ };
36
+
37
+ // No Registry bound — return 0 to signal a wire-layer fault; the guest
38
+ // maps a 0 return to a trap. `Kobako::Sandbox` always installs a
39
+ // Registry before invoking the guest, so reaching this branch indicates
40
+ // a misuse rather than a normal control path.
41
+ let registry = match caller.data().registry {
42
+ Some(d) => d,
43
+ None => return 0,
44
+ };
45
+
46
+ let resp_bytes = match invoke_registry(registry, &req_bytes) {
47
+ Ok(b) => b,
48
+ Err(_) => return 0,
49
+ };
50
+
51
+ write_response(caller, &resp_bytes).unwrap_or(0)
52
+ }
53
+
54
+ /// Call the Ruby Registry's `#dispatch(request_bytes)` method and return
55
+ /// the encoded Response bytes. Errors here mean the Registry itself
56
+ /// failed (it is contracted never to raise — see
57
+ /// `Kobako::Registry#dispatch`), which we treat as a wire-layer fault.
58
+ fn invoke_registry(registry: Opaque<Value>, req_bytes: &[u8]) -> Result<Vec<u8>, MagnusError> {
59
+ // The wasmtime callback runs on the same Ruby thread that called
60
+ // Sandbox#run — the invariant SPEC Implementation Standards
61
+ // Architecture pins for the host gem — so `Ruby::get()` is always
62
+ // available here. Panicking with `expect` localises the violation
63
+ // rather than letting a nonsense error propagate.
64
+ let ruby = Ruby::get().expect("Ruby handle unavailable in __kobako_rpc_call");
65
+ let registry_value: Value = ruby.get_inner(registry);
66
+ let req_str = ruby.str_from_slice(req_bytes);
67
+ let resp: RString = registry_value.funcall("dispatch", (req_str,))?;
68
+ // SAFETY: the returned RString is held by the Ruby VM for the duration of
69
+ // this scope; copying its bytes into a Vec is a defensive standard pattern.
70
+ let bytes = unsafe { resp.as_slice() }.to_vec();
71
+ Ok(bytes)
72
+ }
73
+
74
+ /// Allocate a guest-side buffer through `__kobako_alloc` and copy the
75
+ /// response bytes into it. Returns the packed `(ptr<<32)|len` u64.
76
+ fn write_response(caller: &mut Caller<'_, HostState>, bytes: &[u8]) -> Option<i64> {
77
+ let alloc = match caller.get_export("__kobako_alloc") {
78
+ Some(Extern::Func(f)) => f.typed::<i32, i32>(&*caller).ok()?,
79
+ _ => return None,
80
+ };
81
+ let len_i32 = i32::try_from(bytes.len()).ok()?;
82
+ let ptr = alloc.call(&mut *caller, len_i32).ok()?;
83
+ if ptr == 0 {
84
+ return None;
85
+ }
86
+
87
+ let mem = match caller.get_export("memory") {
88
+ Some(Extern::Memory(m)) => m,
89
+ _ => return None,
90
+ };
91
+ mem.write(&mut *caller, ptr as usize, bytes).ok()?;
92
+
93
+ let ptr_u32 = ptr as u32;
94
+ let len_u32 = bytes.len() as u32;
95
+ Some(((ptr_u32 as i64) << 32) | (len_u32 as i64))
96
+ }
97
+
98
+ /// Copy `[ptr, ptr+len)` out of the guest's linear memory as seen from
99
+ /// `caller`. Returns `None` when `memory` is not exported or the slice
100
+ /// falls outside the live memory range.
101
+ fn read_caller_memory(caller: &mut Caller<'_, HostState>, ptr: i32, len: i32) -> Option<Vec<u8>> {
102
+ let mem = match caller.get_export("memory") {
103
+ Some(Extern::Memory(m)) => m,
104
+ _ => return None,
105
+ };
106
+ let data = mem.data(&caller);
107
+ let start = ptr as usize;
108
+ let end = start.checked_add(len as usize)?;
109
+ data.get(start..end).map(|s| s.to_vec())
110
+ }
@@ -0,0 +1,59 @@
1
+ //! Per-Store host state shared with every wasmtime callback.
2
+ //!
3
+ //! Owned by [`StoreCell`] (a `RefCell` shim wrapping `wasmtime::Store`)
4
+ //! and threaded through every host import — the `__kobako_rpc_call`
5
+ //! dispatcher reads `registry`, while the run-path methods on
6
+ //! [`crate::wasm::Instance`] mutate `wasi`, `stdout_pipe`, `stderr_pipe`
7
+ //! when refreshing the WASI context before each `#run` (SPEC.md B-03 /
8
+ //! B-04).
9
+
10
+ use std::cell::RefCell;
11
+
12
+ use magnus::{value::Opaque, Value};
13
+ use wasmtime::Store as WtStore;
14
+ use wasmtime_wasi::p1::WasiP1Ctx;
15
+ use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
16
+
17
+ /// Per-Store host state threaded through every host import callback.
18
+ ///
19
+ /// WASI p1 state is embedded as `Option<WasiP1Ctx>` so it can be replaced
20
+ /// fresh before each `#run` without rebuilding the Store. The `stdout_pipe`
21
+ /// and `stderr_pipe` clones are kept alongside so the Ruby layer can drain
22
+ /// captured bytes after execution without touching the WASI internals.
23
+ #[derive(Default)]
24
+ pub(crate) struct HostState {
25
+ /// Buffer mirror of guest's OUTCOME_BUFFER. Filled by `__kobako_take_outcome`
26
+ /// post-execution. Reserved for a future streaming-outcome path; not yet
27
+ /// consumed on the Rust side (the Ruby layer reads outcome via read_memory).
28
+ #[allow(dead_code)]
29
+ pub outcome: Vec<u8>,
30
+ /// WASI p1 context for the current (or most-recent) run. Replaced before
31
+ /// each `#run` so stdin/stdout/stderr pipes are always fresh (SPEC.md B-03).
32
+ pub wasi: Option<WasiP1Ctx>,
33
+ /// Clone of the MemoryOutputPipe wired to guest fd 1 (stdout). Retained
34
+ /// here so `take_stdout` can call `contents()` after execution without
35
+ /// having to dig into the WASI ctx internals.
36
+ pub stdout_pipe: Option<MemoryOutputPipe>,
37
+ /// Clone of the MemoryOutputPipe wired to guest fd 2 (stderr).
38
+ pub stderr_pipe: Option<MemoryOutputPipe>,
39
+ /// Ruby-side `Kobako::Registry`. When set, the `__kobako_rpc_call`
40
+ /// import calls `registry.dispatch(req_bytes)` and hands the returned
41
+ /// Response bytes back to the guest. `Opaque<Value>` is `Send + Sync`;
42
+ /// calling `get_inner` requires a `Ruby` handle, which we obtain on
43
+ /// every Ruby thread entry via `Ruby::get()`.
44
+ pub registry: Option<Opaque<Value>>,
45
+ }
46
+
47
+ /// Interior-mutability wrapper around `wasmtime::Store<HostState>`.
48
+ ///
49
+ /// Magnus requires `Send + Sync` for wrapped types. `wasmtime::Store` is not
50
+ /// `Sync`, so we wrap it in a `RefCell`. `RefCell` alone is sufficient
51
+ /// because magnus enforces single-threaded GVL access from Ruby; `Send` and
52
+ /// `Sync` are asserted via the unsafe impls below.
53
+ pub(crate) struct StoreCell(pub(crate) RefCell<WtStore<HostState>>);
54
+
55
+ // SAFETY: Ruby's GVL serialises access to magnus-wrapped objects on a single
56
+ // OS thread at a time. `wasmtime::Store` is `Send` (verified upstream); the
57
+ // `RefCell`-mediated mutation is therefore safe under the GVL invariant.
58
+ unsafe impl Send for StoreCell {}
59
+ unsafe impl Sync for StoreCell {}
@@ -0,0 +1,361 @@
1
+ //! `Kobako::Wasm::Instance` — the only Ruby-visible wasmtime wrapper.
2
+ //!
3
+ //! Constructed via [`Instance::from_path`]; the wasmtime [`Engine`] and
4
+ //! compiled [`Module`] are owned by the [`super::cache`] singletons and
5
+ //! never surface to Ruby. The instance wraps a [`StoreCell`] (interior-
6
+ //! mutability around `wasmtime::Store<HostState>`) plus three cached
7
+ //! [`TypedFunc`] handles for the SPEC ABI exports.
8
+ //!
9
+ //! WASI stdout/stderr capture (SPEC.md B-04): wasmtime-wasi p1 bindings
10
+ //! route guest fd 1 and fd 2 into per-run [`MemoryOutputPipe`] instances.
11
+ //! After each run the host drains the pipes via [`Instance::take_stdout`]
12
+ //! / [`Instance::take_stderr`] and pushes the raw bytes through Ruby's
13
+ //! OutputBuffer (which enforces the cap and `[truncated]` marker).
14
+ //! Stdin carries the two-frame length-prefixed protocol: Frame 1
15
+ //! (preamble msgpack) followed by Frame 2 (user script UTF-8), each
16
+ //! prefixed by a 4-byte big-endian u32 length. Written via
17
+ //! [`Instance::setup_wasi_pipes`] before each run (SPEC.md ABI
18
+ //! Signatures).
19
+ //!
20
+ //! [`Engine`]: wasmtime::Engine
21
+ //! [`Module`]: wasmtime::Module
22
+ //! [`TypedFunc`]: wasmtime::TypedFunc
23
+ //! [`MemoryOutputPipe`]: wasmtime_wasi::p2::pipe::MemoryOutputPipe
24
+
25
+ use std::cell::RefCell;
26
+ use std::path::Path;
27
+
28
+ use magnus::RString;
29
+ use magnus::{value::Opaque, Error as MagnusError, Ruby, Value};
30
+ use wasmtime::{
31
+ AsContextMut, Caller, Engine as WtEngine, Extern, Instance as WtInstance, Linker, Memory,
32
+ Module as WtModule, Store as WtStore, TypedFunc,
33
+ };
34
+ use wasmtime_wasi::p1;
35
+ use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
36
+ use wasmtime_wasi::WasiCtxBuilder;
37
+
38
+ use super::cache::{cached_module, shared_engine};
39
+ use super::dispatch::dispatch_rpc;
40
+ use super::host_state::{HostState, StoreCell};
41
+ use super::wasm_err;
42
+
43
+ #[magnus::wrap(class = "Kobako::Wasm::Instance", free_immediately, size)]
44
+ pub(crate) struct Instance {
45
+ inner: WtInstance,
46
+ store: StoreCell,
47
+ // Cached TypedFunc handles for the three guest exports. Optional because
48
+ // test fixtures (a minimal "ping" module) need not provide them; real
49
+ // kobako.wasm always does, and the host enforces presence at run time.
50
+ //
51
+ // Exactly the SPEC ABI shape: `__kobako_run() -> ()`. Source delivery
52
+ // uses the WASI stdin two-frame protocol (see `setup_wasi_frames`).
53
+ run: Option<TypedFunc<(), ()>>,
54
+ take_outcome: Option<TypedFunc<(), u64>>,
55
+ alloc: Option<TypedFunc<i32, i32>>,
56
+ }
57
+
58
+ impl Instance {
59
+ /// Construct an Instance from a wasm file path, using the process-wide
60
+ /// shared Engine and per-path Module cache. The single Ruby-facing
61
+ /// constructor for `Kobako::Wasm::Instance` — Engine and Module are
62
+ /// never visible to Ruby.
63
+ pub(crate) fn from_path(path: String) -> Result<Self, MagnusError> {
64
+ let engine = shared_engine()?;
65
+ let module = cached_module(Path::new(&path))?;
66
+ let store = WtStore::new(engine, HostState::default());
67
+ let store_cell = StoreCell(RefCell::new(store));
68
+ build_instance(engine, &module, store_cell)
69
+ }
70
+
71
+ /// Install the Ruby-side `Kobako::Registry` into HostState. Called by
72
+ /// `Kobako::Sandbox` after constructing the Registry; from this point
73
+ /// on, every `__kobako_rpc_call` import invocation routes through
74
+ /// `registry.dispatch(req_bytes)`.
75
+ pub(crate) fn set_registry(&self, registry: Value) -> Result<(), MagnusError> {
76
+ let mut store_ref = self.store.0.borrow_mut();
77
+ store_ref.data_mut().registry = Some(Opaque::from(registry));
78
+ Ok(())
79
+ }
80
+
81
+ // -----------------------------------------------------------------
82
+ // Run-path methods. These drive the alloc → write source → run →
83
+ // take_outcome flow from Ruby. Each method is best-effort — it raises
84
+ // a Ruby `Kobako::Wasm::Error` when the corresponding export is
85
+ // missing or fails so the Sandbox layer can map errors to the
86
+ // three-class taxonomy.
87
+ // -----------------------------------------------------------------
88
+
89
+ /// Invoke the guest's `__kobako_alloc(size)` export and return the
90
+ /// resulting linear-memory offset. A return of 0 indicates an
91
+ /// allocation failure (caller should treat as a wire violation /
92
+ /// trap).
93
+ pub(crate) fn alloc(&self, size: i32) -> Result<i32, MagnusError> {
94
+ let ruby = Ruby::get().expect("Ruby thread");
95
+ let alloc = self
96
+ .alloc
97
+ .as_ref()
98
+ .ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_alloc"))?;
99
+ let mut store_ref = self.store.0.borrow_mut();
100
+ alloc
101
+ .call(store_ref.as_context_mut(), size)
102
+ .map_err(|e| wasm_err(&ruby, format!("__kobako_alloc({}): {}", size, e)))
103
+ }
104
+
105
+ /// Write +bytes+ into the guest's linear memory starting at +ptr+.
106
+ /// Raises `Kobako::Wasm::Error` if the instance has no `memory`
107
+ /// export or the slice is out of bounds.
108
+ pub(crate) fn write_memory(&self, ptr: i32, bytes: RString) -> Result<(), MagnusError> {
109
+ let ruby = Ruby::get().expect("Ruby thread");
110
+ let mut store_ref = self.store.0.borrow_mut();
111
+ let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
112
+ Some(Extern::Memory(m)) => m,
113
+ _ => return Err(wasm_err(&ruby, "guest does not export 'memory'")),
114
+ };
115
+
116
+ // SAFETY: RString::as_slice on a frozen-on-read borrow.
117
+ let src: &[u8] = unsafe { bytes.as_slice() };
118
+ let data = mem.data_mut(store_ref.as_context_mut());
119
+ let start = ptr as usize;
120
+ let end = start
121
+ .checked_add(src.len())
122
+ .ok_or_else(|| wasm_err(&ruby, "write_memory: ptr + len overflow"))?;
123
+ if end > data.len() {
124
+ return Err(wasm_err(
125
+ &ruby,
126
+ format!(
127
+ "write_memory: range [{}, {}) exceeds memory size {}",
128
+ start,
129
+ end,
130
+ data.len()
131
+ ),
132
+ ));
133
+ }
134
+ data[start..end].copy_from_slice(src);
135
+ Ok(())
136
+ }
137
+
138
+ /// Read +len+ bytes from the guest's linear memory starting at
139
+ /// +ptr+. Returns a binary-encoded Ruby String.
140
+ pub(crate) fn read_memory(&self, ptr: i32, len: i32) -> Result<RString, MagnusError> {
141
+ let ruby = Ruby::get().expect("Ruby thread");
142
+ let mut store_ref = self.store.0.borrow_mut();
143
+ let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
144
+ Some(Extern::Memory(m)) => m,
145
+ _ => return Err(wasm_err(&ruby, "guest does not export 'memory'")),
146
+ };
147
+ let data = mem.data(store_ref.as_context_mut());
148
+ let start = ptr as usize;
149
+ let end = start
150
+ .checked_add(len as usize)
151
+ .ok_or_else(|| wasm_err(&ruby, "read_memory: ptr + len overflow"))?;
152
+ if end > data.len() {
153
+ return Err(wasm_err(
154
+ &ruby,
155
+ format!(
156
+ "read_memory: range [{}, {}) exceeds memory size {}",
157
+ start,
158
+ end,
159
+ data.len()
160
+ ),
161
+ ));
162
+ }
163
+ Ok(ruby.str_from_slice(&data[start..end]))
164
+ }
165
+
166
+ /// Invoke `__kobako_run` with the SPEC `() -> ()` shape. Source is
167
+ /// delivered via the WASI stdin two-frame protocol written by
168
+ /// `setup_wasi_frames` before this call.
169
+ pub(crate) fn run_call(&self) -> Result<(), MagnusError> {
170
+ let ruby = Ruby::get().expect("Ruby thread");
171
+ let run = self
172
+ .run
173
+ .as_ref()
174
+ .ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_run"))?;
175
+ let mut store_ref = self.store.0.borrow_mut();
176
+ run.call(store_ref.as_context_mut(), ())
177
+ .map_err(|e| wasm_err(&ruby, format!("__kobako_run(): {}", e)))
178
+ }
179
+
180
+ /// Invoke `__kobako_take_outcome`. Returns the packed u64
181
+ /// `(ptr << 32) | len`; the Ruby caller unpacks.
182
+ pub(crate) fn take_outcome(&self) -> Result<u64, MagnusError> {
183
+ let ruby = Ruby::get().expect("Ruby thread");
184
+ let take = self
185
+ .take_outcome
186
+ .as_ref()
187
+ .ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_take_outcome"))?;
188
+ let mut store_ref = self.store.0.borrow_mut();
189
+ take.call(store_ref.as_context_mut(), ())
190
+ .map_err(|e| wasm_err(&ruby, format!("__kobako_take_outcome(): {}", e)))
191
+ }
192
+
193
+ // -----------------------------------------------------------------
194
+ // WASI capture path (SPEC.md B-04). Called by Ruby's Sandbox#run.
195
+ // -----------------------------------------------------------------
196
+
197
+ /// Initialise fresh WASI pipes with the two-frame stdin content.
198
+ ///
199
+ /// Must be called before each `run` invocation. Creates:
200
+ /// * A MemoryInputPipe for stdin with Frame 1 (preamble) + Frame 2
201
+ /// (user script) encoded as length-prefixed frames: each frame is a
202
+ /// 4-byte big-endian u32 length prefix followed by the payload bytes.
203
+ /// The guest reads both frames from WASI stdin (SPEC.md ABI Signatures).
204
+ /// * A MemoryOutputPipe for fd 1 (stdout) — transport-layer pipe.
205
+ /// * A MemoryOutputPipe for fd 2 (stderr) — transport-layer pipe.
206
+ ///
207
+ /// `stdout_cap` and `stderr_cap` are accepted but the transport pipes are
208
+ /// uncapped: SPEC.md B-04 requires that overflowing the OutputBuffer limit
209
+ /// is a non-error outcome. A capped WASI pipe would produce a real trap.
210
+ pub(crate) fn setup_wasi_pipes(
211
+ &self,
212
+ stdout_cap: i64,
213
+ stderr_cap: i64,
214
+ preamble_bytes: RString,
215
+ source_bytes: RString,
216
+ ) -> Result<(), MagnusError> {
217
+ let _ = (stdout_cap, stderr_cap);
218
+
219
+ // Build the two-frame stdin content. Each frame: 4-byte BE u32 length
220
+ // prefix + payload bytes (SPEC.md ABI Signatures — two-frame protocol).
221
+ let preamble: &[u8] = unsafe { preamble_bytes.as_slice() };
222
+ let source: &[u8] = unsafe { source_bytes.as_slice() };
223
+
224
+ let mut stdin_content: Vec<u8> = Vec::with_capacity(4 + preamble.len() + 4 + source.len());
225
+ // Frame 1 — preamble
226
+ stdin_content.extend_from_slice(&(preamble.len() as u32).to_be_bytes());
227
+ stdin_content.extend_from_slice(preamble);
228
+ // Frame 2 — user script
229
+ stdin_content.extend_from_slice(&(source.len() as u32).to_be_bytes());
230
+ stdin_content.extend_from_slice(source);
231
+
232
+ let stdin_pipe = MemoryInputPipe::new(stdin_content);
233
+ let stdout_pipe = MemoryOutputPipe::new(usize::MAX);
234
+ let stderr_pipe = MemoryOutputPipe::new(usize::MAX);
235
+
236
+ let mut builder = WasiCtxBuilder::new();
237
+ builder.stdin(stdin_pipe);
238
+ builder.stdout(stdout_pipe.clone());
239
+ builder.stderr(stderr_pipe.clone());
240
+ let wasi = builder.build_p1();
241
+
242
+ {
243
+ let mut store_ref = self.store.0.borrow_mut();
244
+ let data = store_ref.data_mut();
245
+ data.wasi = Some(wasi);
246
+ data.stdout_pipe = Some(stdout_pipe);
247
+ data.stderr_pipe = Some(stderr_pipe);
248
+ }
249
+
250
+ Ok(())
251
+ }
252
+
253
+ /// Drain the stdout bytes captured during the most recent run.
254
+ ///
255
+ /// Returns a binary Ruby String containing raw bytes from guest fd 1.
256
+ /// The MemoryOutputPipe clone in HostState retains its contents between
257
+ /// calls — it is replaced on the next `setup_wasi_pipes`.
258
+ pub(crate) fn take_stdout(&self) -> Result<RString, MagnusError> {
259
+ let ruby = Ruby::get().expect("Ruby thread");
260
+ let store_ref = self.store.0.borrow();
261
+ let bytes = store_ref
262
+ .data()
263
+ .stdout_pipe
264
+ .as_ref()
265
+ .map(|p| p.contents())
266
+ .unwrap_or_default();
267
+ Ok(ruby.str_from_slice(&bytes))
268
+ }
269
+
270
+ /// Drain the stderr bytes captured during the most recent run.
271
+ ///
272
+ /// Returns a binary Ruby String containing raw bytes from guest fd 2.
273
+ pub(crate) fn take_stderr(&self) -> Result<RString, MagnusError> {
274
+ let ruby = Ruby::get().expect("Ruby thread");
275
+ let store_ref = self.store.0.borrow();
276
+ let bytes = store_ref
277
+ .data()
278
+ .stderr_pipe
279
+ .as_ref()
280
+ .map(|p| p.contents())
281
+ .unwrap_or_default();
282
+ Ok(ruby.str_from_slice(&bytes))
283
+ }
284
+ }
285
+
286
+ /// Build an `Instance` from an engine, module, and store cell. The store
287
+ /// cell is moved in and ends up owned by the returned Instance. Wires
288
+ /// the WASI p1 imports plus the `__kobako_rpc_call` host import.
289
+ fn build_instance(
290
+ engine: &WtEngine,
291
+ module: &WtModule,
292
+ store_cell: StoreCell,
293
+ ) -> Result<Instance, MagnusError> {
294
+ let ruby = Ruby::get().expect("Ruby thread");
295
+ let mut linker: Linker<HostState> = Linker::new(engine);
296
+
297
+ // Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2 to
298
+ // the MemoryOutputPipes set up before each run via `setup_wasi_pipes`.
299
+ // The closure extracts `&mut WasiP1Ctx` from HostState; if none is set
300
+ // (e.g. a test module that never invokes WASI functions), the Option
301
+ // unwrap will panic — but `setup_wasi_pipes` is always called before any
302
+ // WASI-enabled run.
303
+ p1::add_to_linker_sync(&mut linker, |state: &mut HostState| {
304
+ state
305
+ .wasi
306
+ .as_mut()
307
+ .expect("WASI context not initialised — call setup_wasi_pipes before run")
308
+ })
309
+ .map_err(|e| wasm_err(&ruby, format!("add WASI p1 to linker: {}", e)))?;
310
+
311
+ // `__kobako_rpc_call` host import. Signature per SPEC Wire ABI:
312
+ // (req_ptr: i32, req_len: i32) -> i64
313
+ // Decodes the Request bytes, dispatches via the Ruby-side
314
+ // `Kobako::Registry` (set per-run via `set_registry`), allocates a guest
315
+ // buffer through `__kobako_alloc`, writes the Response bytes there, and
316
+ // returns the packed `(ptr<<32)|len`. The dispatcher returns 0 on any
317
+ // wire-layer fault (including a missing Registry); see `dispatch_rpc`.
318
+ linker
319
+ .func_wrap(
320
+ "env",
321
+ "__kobako_rpc_call",
322
+ |mut caller: Caller<'_, HostState>, req_ptr: i32, req_len: i32| -> i64 {
323
+ dispatch_rpc(&mut caller, req_ptr, req_len)
324
+ },
325
+ )
326
+ .map_err(|e| wasm_err(&ruby, format!("define __kobako_rpc_call: {}", e)))?;
327
+
328
+ let instance = {
329
+ let mut store_ref = store_cell.0.borrow_mut();
330
+ linker
331
+ .instantiate(store_ref.as_context_mut(), module)
332
+ .map_err(|e| wasm_err(&ruby, format!("instantiate: {}", e)))?
333
+ };
334
+
335
+ // Best-effort export lookup. Missing exports are not an error here
336
+ // (test fixture is a bare module); the host enforces presence before
337
+ // invocation. Only the SPEC ABI `() -> ()` shape is accepted for
338
+ // `__kobako_run` — the transitional `(ptr, len) -> ()` shape is gone.
339
+ let (run, take_outcome, alloc) = {
340
+ let mut store_ref = store_cell.0.borrow_mut();
341
+ let mut ctx = store_ref.as_context_mut();
342
+ let run = instance
343
+ .get_typed_func::<(), ()>(&mut ctx, "__kobako_run")
344
+ .ok();
345
+ let take_outcome = instance
346
+ .get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
347
+ .ok();
348
+ let alloc = instance
349
+ .get_typed_func::<i32, i32>(&mut ctx, "__kobako_alloc")
350
+ .ok();
351
+ (run, take_outcome, alloc)
352
+ };
353
+
354
+ Ok(Instance {
355
+ inner: instance,
356
+ store: store_cell,
357
+ run,
358
+ take_outcome,
359
+ alloc,
360
+ })
361
+ }
@@ -0,0 +1,80 @@
1
+ // Host-side wasmtime wrapper.
2
+ //
3
+ // The only Ruby-visible class is
4
+ //
5
+ // Kobako::Wasm::Instance — wraps wasmtime::Instance + cached TypedFuncs
6
+ //
7
+ // constructed via `Kobako::Wasm::Instance.from_path(path)`. The underlying
8
+ // wasmtime Engine and compiled Module live in a process-scope cache (see
9
+ // the `cache` submodule) and never surface to Ruby (SPEC.md "Code
10
+ // Organization": `ext/` "exposes no Wasm engine types to the Host App or
11
+ // downstream gems").
12
+ //
13
+ // Module layout (per CLAUDE.md principle #2 — one responsibility per file):
14
+ //
15
+ // * `cache` — process-wide Engine + per-path Module cache.
16
+ // * `host_state` — HostState (per-Store context) + StoreCell wrapper.
17
+ // * `instance` — Kobako::Wasm::Instance and its run-path methods.
18
+ // * `dispatch` — `__kobako_rpc_call` host-import dispatch helpers.
19
+ //
20
+ // This file is the façade: it owns the Ruby error class lazy-resolvers,
21
+ // the `wasm_err` constructor shared by every submodule, and the Ruby
22
+ // init() that registers `Kobako::Wasm::Instance` and its methods.
23
+
24
+ mod cache;
25
+ mod dispatch;
26
+ mod host_state;
27
+ mod instance;
28
+
29
+ use magnus::value::Lazy;
30
+ use magnus::{function, method, prelude::*, Error as MagnusError, ExceptionClass, RModule, Ruby};
31
+
32
+ use instance::Instance;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Error classes (lazy-resolved from Ruby once Kobako::Wasm is defined).
36
+ // ---------------------------------------------------------------------------
37
+
38
+ pub(crate) static MODULE_NOT_BUILT_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
39
+ let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
40
+ let wasm: RModule = kobako.const_get("Wasm").unwrap();
41
+ wasm.const_get("ModuleNotBuiltError").unwrap()
42
+ });
43
+
44
+ pub(crate) static WASM_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
45
+ let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
46
+ let wasm: RModule = kobako.const_get("Wasm").unwrap();
47
+ wasm.const_get("Error").unwrap()
48
+ });
49
+
50
+ pub(crate) fn wasm_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
51
+ MagnusError::new(ruby.get_inner(&WASM_ERROR), msg.into())
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Ruby init
56
+ // ---------------------------------------------------------------------------
57
+
58
+ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
59
+ let wasm = kobako.define_module("Wasm")?;
60
+
61
+ // Error hierarchy. ModuleNotBuiltError is the headline error for the
62
+ // common pre-build state where `data/kobako.wasm` has not yet been
63
+ // produced (e.g. fresh clone before `rake compile`).
64
+ let base_err = wasm.define_error("Error", ruby.exception_standard_error())?;
65
+ wasm.define_error("ModuleNotBuiltError", base_err)?;
66
+
67
+ let instance = wasm.define_class("Instance", ruby.class_object())?;
68
+ instance.define_singleton_method("from_path", function!(Instance::from_path, 1))?;
69
+ instance.define_method("alloc", method!(Instance::alloc, 1))?;
70
+ instance.define_method("write_memory", method!(Instance::write_memory, 2))?;
71
+ instance.define_method("read_memory", method!(Instance::read_memory, 2))?;
72
+ instance.define_method("run", method!(Instance::run_call, 0))?;
73
+ instance.define_method("take_outcome", method!(Instance::take_outcome, 0))?;
74
+ instance.define_method("set_registry", method!(Instance::set_registry, 1))?;
75
+ instance.define_method("setup_wasi_pipes", method!(Instance::setup_wasi_pipes, 4))?;
76
+ instance.define_method("take_stdout", method!(Instance::take_stdout, 0))?;
77
+ instance.define_method("take_stderr", method!(Instance::take_stderr, 0))?;
78
+
79
+ Ok(())
80
+ }