kobako 0.2.1 → 0.4.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +205 -59
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +2 -2
  6. data/ext/kobako/src/wasm/cache.rs +15 -7
  7. data/ext/kobako/src/wasm/dispatch.rs +88 -36
  8. data/ext/kobako/src/wasm/host_state.rs +298 -55
  9. data/ext/kobako/src/wasm/instance.rs +477 -160
  10. data/ext/kobako/src/wasm.rs +20 -5
  11. data/lib/kobako/capture.rb +12 -10
  12. data/lib/kobako/codec/decoder.rb +3 -4
  13. data/lib/kobako/codec/encoder.rb +1 -1
  14. data/lib/kobako/codec/error.rb +3 -2
  15. data/lib/kobako/codec/factory.rb +24 -17
  16. data/lib/kobako/codec/utils.rb +105 -12
  17. data/lib/kobako/codec.rb +2 -1
  18. data/lib/kobako/errors.rb +22 -10
  19. data/lib/kobako/handle.rb +62 -0
  20. data/lib/kobako/handle_table.rb +119 -0
  21. data/lib/kobako/invocation.rb +143 -0
  22. data/lib/kobako/outcome/panic.rb +2 -2
  23. data/lib/kobako/outcome.rb +61 -24
  24. data/lib/kobako/rpc/dispatcher.rb +30 -28
  25. data/lib/kobako/rpc/envelope.rb +10 -10
  26. data/lib/kobako/rpc/fault.rb +4 -3
  27. data/lib/kobako/rpc/namespace.rb +3 -3
  28. data/lib/kobako/rpc/server.rb +23 -33
  29. data/lib/kobako/rpc/wire_error.rb +23 -0
  30. data/lib/kobako/sandbox.rb +211 -136
  31. data/lib/kobako/sandbox_options.rb +73 -0
  32. data/lib/kobako/snippet/binary.rb +30 -0
  33. data/lib/kobako/snippet/source.rb +28 -0
  34. data/lib/kobako/snippet/table.rb +174 -0
  35. data/lib/kobako/snippet.rb +20 -0
  36. data/lib/kobako/usage.rb +41 -0
  37. data/lib/kobako/version.rb +1 -1
  38. data/lib/kobako.rb +1 -0
  39. data/sig/kobako/codec/factory.rbs +1 -1
  40. data/sig/kobako/codec/utils.rbs +10 -0
  41. data/sig/kobako/errors.rbs +3 -0
  42. data/sig/kobako/handle.rbs +19 -0
  43. data/sig/kobako/handle_table.rbs +23 -0
  44. data/sig/kobako/invocation.rbs +25 -0
  45. data/sig/kobako/outcome.rbs +1 -1
  46. data/sig/kobako/rpc/dispatcher.rbs +7 -7
  47. data/sig/kobako/rpc/envelope.rbs +3 -3
  48. data/sig/kobako/rpc/server.rbs +1 -7
  49. data/sig/kobako/rpc/wire_error.rbs +6 -0
  50. data/sig/kobako/sandbox.rbs +22 -17
  51. data/sig/kobako/sandbox_options.rbs +32 -0
  52. data/sig/kobako/snippet/binary.rbs +12 -0
  53. data/sig/kobako/snippet/source.rbs +13 -0
  54. data/sig/kobako/snippet/table.rbs +36 -0
  55. data/sig/kobako/snippet.rbs +4 -0
  56. data/sig/kobako/usage.rbs +11 -0
  57. data/sig/kobako/wasm.rbs +5 -1
  58. metadata +21 -5
  59. data/lib/kobako/rpc/handle.rb +0 -38
  60. data/lib/kobako/rpc/handle_table.rb +0 -107
  61. data/sig/kobako/rpc/handle.rbs +0 -19
  62. data/sig/kobako/rpc/handle_table.rbs +0 -25
@@ -3,32 +3,37 @@
3
3
  //! Constructed via [`Instance::from_path`]; the wasmtime [`Engine`] and
4
4
  //! compiled [`Module`] are owned by the [`super::cache`] singletons and
5
5
  //! never surface to Ruby. The instance wraps a [`StoreCell`] (interior-
6
- //! mutability around `wasmtime::Store<HostState>`) plus two cached
7
- //! [`TypedFunc`] handles for the SPEC ABI exports used by the host-driven
8
- //! run path.
6
+ //! mutability around `wasmtime::Store<HostState>`) plus three cached
7
+ //! [`TypedFunc`] handles for the docs/wire-codec.md ABI exports used by
8
+ //! the host-driven run path.
9
9
  //!
10
10
  //! The Ruby surface intentionally exposes intent, not the underlying ABI
11
- //! (SPEC.md "Code Organization"). The two-frame stdin protocol, packed-u64
12
- //! outcome encoding, and `__kobako_alloc` / `__kobako_take_outcome` /
13
- //! `__kobako_run` exports are all wrapped inside [`Instance::run`] and
14
- //! [`Instance::outcome`]; Ruby callers see only `#run(preamble, source)`,
15
- //! `#stdout`, `#stderr`, `#outcome!`, and `#server=`.
11
+ //! (SPEC.md "Code Organization"). The length-prefixed stdin frame
12
+ //! protocol (three frames for `#eval`: preamble + source + snippets;
13
+ //! two for `#run`: preamble + snippets), packed-u64 outcome encoding,
14
+ //! and the `__kobako_eval` / `__kobako_run` / `__kobako_alloc` /
15
+ //! `__kobako_take_outcome` exports are all wrapped inside
16
+ //! [`Instance::eval`], [`Instance::run`], and [`Instance::outcome`];
17
+ //! Ruby callers see only `#eval(preamble, source, snippets)`,
18
+ //! `#run(preamble, snippets, envelope)`, `#stdout`, `#stderr`,
19
+ //! `#outcome!`, and `#server=`.
16
20
  //!
17
- //! WASI stdout/stderr capture (SPEC.md B-04): wasmtime-wasi p1 bindings
18
- //! route guest fd 1 and fd 2 into per-run [`MemoryOutputPipe`] instances
19
- //! rebuilt at the start of every [`Instance::run`]. The per-channel cap
20
- //! is enforced directly on the pipe — the pipe is sized at `cap + 1` so
21
- //! a guest that writes exactly `cap` bytes is distinguishable from one
22
- //! that exceeded the cap, and `#stdout` / `#stderr` slice the captured
23
- //! bytes back to `cap` before returning them paired with a truncation
24
- //! flag. Uncapped channels (`None`) build the pipe at `usize::MAX`;
25
- //! `memory_limit` provides the real upper bound in that case.
21
+ //! WASI stdout/stderr capture (docs/behavior.md B-04): wasmtime-wasi p1
22
+ //! bindings route guest fd 1 and fd 2 into per-run [`MemoryOutputPipe`]
23
+ //! instances rebuilt at the start of every [`Instance::eval`] /
24
+ //! [`Instance::run`]. The per-channel cap is enforced directly on the
25
+ //! pipe the pipe is sized at `cap + 1` so a guest that writes exactly
26
+ //! `cap` bytes is distinguishable from one that exceeded the cap, and
27
+ //! `#stdout` / `#stderr` slice the captured bytes back to `cap` before
28
+ //! returning them paired with a truncation flag. Uncapped channels
29
+ //! (`None`) build the pipe at `usize::MAX`; `memory_limit` provides
30
+ //! the real upper bound in that case.
26
31
  //!
27
- //! Per-run cap enforcement (SPEC.md B-01, E-19, E-20): every Store
28
- //! installs an epoch-deadline callback for wall-clock timeout and a
29
- //! [`ResourceLimiter`] for the linear-memory cap. Wasmtime turns
30
- //! limiter / callback errors into traps; `Instance::run` downcasts the
31
- //! trap source to surface as `Kobako::Wasm::TimeoutError` or
32
+ //! Per-run cap enforcement (docs/behavior.md B-01, E-19, E-20): every
33
+ //! Store installs an epoch-deadline callback for wall-clock timeout and
34
+ //! a [`ResourceLimiter`] for the linear-memory cap. Wasmtime turns
35
+ //! limiter / callback errors into traps; the run-path methods downcast
36
+ //! the trap source to surface as `Kobako::Wasm::TimeoutError` or
32
37
  //! `Kobako::Wasm::MemoryLimitError` so the `Sandbox` layer can map them
33
38
  //! to the named `Kobako::TrapError` subclasses.
34
39
  //!
@@ -53,7 +58,7 @@ use wasmtime_wasi::WasiCtxBuilder;
53
58
  use super::cache::{cached_module, shared_engine};
54
59
  use super::dispatch;
55
60
  use super::host_state::{HostState, MemoryLimitTrap, StoreCell, TimeoutTrap};
56
- use super::{memory_limit_err, timeout_err, wasm_err};
61
+ use super::{memory_limit_err, rstring_to_vec, timeout_err, wasm_err};
57
62
 
58
63
  #[magnus::wrap(class = "Kobako::Wasm::Instance", free_immediately, size)]
59
64
  pub(crate) struct Instance {
@@ -66,14 +71,16 @@ pub(crate) struct Instance {
66
71
  //
67
72
  // `__kobako_alloc` is NOT cached here — only `dispatch.rs` calls it,
68
73
  // and it does so through `Caller::get_export` on the wasmtime side.
69
- run: Option<TypedFunc<(), ()>>,
74
+ eval: Option<TypedFunc<(), ()>>,
75
+ run: Option<TypedFunc<(i32, i32), ()>>,
70
76
  take_outcome: Option<TypedFunc<(), u64>>,
71
- // Wall-clock cap for one guest `#run` (SPEC.md B-01); `None` disables
77
+ // Wall-clock cap for one guest `#run` (docs/behavior.md B-01); `None` disables
72
78
  // the cap. Translated into an `Instant`-based deadline stamped into
73
- // [`HostState`] at the top of every `Instance::run`.
79
+ // [`HostState`] at the top of every `Instance::eval`.
74
80
  timeout: Option<Duration>,
75
- // Per-channel byte caps for guest stdout / stderr capture (SPEC.md
76
- // B-01 / B-04). `None` disables the cap on that channel. Read by
81
+ // Per-channel byte caps for guest stdout / stderr capture
82
+ // (docs/behavior.md B-01 / B-04). `None` disables the cap on that
83
+ // channel. Read by
77
84
  // [`Instance::refresh_wasi`] to size the MemoryOutputPipe and by
78
85
  // [`Instance::stdout`] / [`Instance::stderr`] to compute the
79
86
  // truncation flag. See the module-level note above for the `cap + 1`
@@ -91,10 +98,10 @@ impl Instance {
91
98
  /// constructor for `Kobako::Wasm::Instance` — Engine and Module are
92
99
  /// never visible to Ruby.
93
100
  ///
94
- /// `timeout_seconds` is the SPEC.md B-01 wall-clock cap in seconds
101
+ /// `timeout_seconds` is the docs/behavior.md B-01 wall-clock cap in seconds
95
102
  /// (`None` disables); `memory_limit` is the linear-memory cap in
96
103
  /// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
97
- /// are the per-channel output caps (SPEC.md B-01 / B-04; `None`
104
+ /// are the per-channel output caps (docs/behavior.md B-01 / B-04; `None`
98
105
  /// disables). All four are validated by the caller
99
106
  /// (`Kobako::Sandbox`); this method only refuses non-finite or
100
107
  /// non-positive timeouts as a defence in depth.
@@ -112,7 +119,7 @@ impl Instance {
112
119
  Some(secs) => {
113
120
  return Err(wasm_err(
114
121
  &ruby,
115
- format!("timeout_seconds must be > 0 and finite, got {secs}"),
122
+ format!("timeout must be > 0 and finite, got {secs} seconds"),
116
123
  ));
117
124
  }
118
125
  };
@@ -151,13 +158,20 @@ impl Instance {
151
158
 
152
159
  // Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2
153
160
  // to the MemoryOutputPipes set up before each run via
154
- // `Instance::run`. The closure pulls a `&mut WasiP1Ctx` out of
161
+ // `Instance::eval`. The closure pulls a `&mut WasiP1Ctx` out of
155
162
  // HostState; the panic semantics live inside `HostState::wasi_mut`
156
163
  // so the wiring stays honest about its precondition.
157
- p1::add_to_linker_sync(&mut linker, |state: &mut HostState| state.wasi_mut())
158
- .map_err(|e| wasm_err(&ruby, format!("add WASI p1 to linker: {}", e)))?;
164
+ p1::add_to_linker_sync(&mut linker, |state: &mut HostState| state.wasi_mut()).map_err(
165
+ |e| {
166
+ wasm_err(
167
+ &ruby,
168
+ format!("failed to wire WASI runtime into Sandbox: {}", e),
169
+ )
170
+ },
171
+ )?;
159
172
 
160
- // `__kobako_dispatch` host import. Signature per SPEC Wire ABI:
173
+ // `__kobako_dispatch` host import. Signature per docs/wire-codec.md
174
+ // § ABI Signatures:
161
175
  // (req_ptr: i32, req_len: i32) -> i64
162
176
  // Decodes the Request bytes, dispatches via the Ruby-side
163
177
  // `Kobako::RPC::Server` (set per-run via `set_server`), allocates a
@@ -173,7 +187,12 @@ impl Instance {
173
187
  dispatch::handle(&mut caller, req_ptr, req_len)
174
188
  },
175
189
  )
176
- .map_err(|e| wasm_err(&ruby, format!("define __kobako_dispatch: {}", e)))?;
190
+ .map_err(|e| {
191
+ wasm_err(
192
+ &ruby,
193
+ format!("failed to register host RPC dispatch import: {}", e),
194
+ )
195
+ })?;
177
196
 
178
197
  let instance = {
179
198
  let mut store_ref = store_cell.borrow_mut();
@@ -186,22 +205,28 @@ impl Instance {
186
205
  // (test fixture is a bare module); the host enforces presence at
187
206
  // invocation time by raising a Ruby `Kobako::Wasm::Error` when the
188
207
  // cached Option is None. Only the SPEC ABI `() -> ()` shape is
189
- // accepted for `__kobako_run`.
190
- let (run, take_outcome) = {
208
+ // accepted for `__kobako_eval`; `__kobako_run` takes
209
+ // `(env_ptr, env_len) -> ()` per docs/wire-codec.md § ABI
210
+ // Signatures.
211
+ let (eval, run, take_outcome) = {
191
212
  let mut store_ref = store_cell.borrow_mut();
192
213
  let mut ctx = store_ref.as_context_mut();
214
+ let eval = instance
215
+ .get_typed_func::<(), ()>(&mut ctx, "__kobako_eval")
216
+ .ok();
193
217
  let run = instance
194
- .get_typed_func::<(), ()>(&mut ctx, "__kobako_run")
218
+ .get_typed_func::<(i32, i32), ()>(&mut ctx, "__kobako_run")
195
219
  .ok();
196
220
  let take_outcome = instance
197
221
  .get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
198
222
  .ok();
199
- (run, take_outcome)
223
+ (eval, run, take_outcome)
200
224
  };
201
225
 
202
226
  Ok(Self {
203
227
  inner: instance,
204
228
  store: store_cell,
229
+ eval,
205
230
  run,
206
231
  take_outcome,
207
232
  timeout,
@@ -227,25 +252,63 @@ impl Instance {
227
252
  // taxonomy.
228
253
  // -----------------------------------------------------------------
229
254
 
230
- /// Execute one guest run.
255
+ /// Execute one guest invocation (`__kobako_eval` — one-shot source).
231
256
  ///
232
257
  /// Rebuilds the WASI context with fresh stdin / stdout / stderr pipes
233
- /// (the two-frame stdin protocol carries +preamble+ then +source+
234
- /// SPEC.md ABI Signatures), then invokes `__kobako_run`. Per-run
235
- /// caps (SPEC.md B-01) are primed here: the wall-clock deadline is
236
- /// stamped into [`HostState`] and the epoch deadline is set to fire
237
- /// at the next ticker tick; the memory-cap limiter is already wired.
238
- pub(crate) fn run(&self, preamble: RString, source: RString) -> Result<(), MagnusError> {
258
+ /// (the three-frame stdin protocol carries +preamble+, +source+, then
259
+ /// +snippets+ — docs/wire-codec.md § Invocation channels), then
260
+ /// invokes `__kobako_eval`. Per-invocation caps (docs/behavior.md
261
+ /// B-01) are primed here: the wall-clock deadline is stamped into
262
+ /// [`HostState`] and the epoch deadline is set to fire at the next
263
+ /// ticker tick; the memory-cap limiter is already wired.
264
+ pub(crate) fn eval(
265
+ &self,
266
+ preamble: RString,
267
+ source: RString,
268
+ snippets: RString,
269
+ ) -> Result<(), MagnusError> {
270
+ let ruby = Ruby::get().expect("Ruby thread");
271
+ let eval = require_export(&ruby, self.eval.as_ref(), "__kobako_eval")?;
272
+ self.refresh_wasi(&[
273
+ rstring_to_vec(preamble),
274
+ rstring_to_vec(source),
275
+ rstring_to_vec(snippets),
276
+ ]);
277
+ self.prime_caps();
278
+ let result = {
279
+ let mut store_ref = self.store.borrow_mut();
280
+ eval.call(store_ref.as_context_mut(), ())
281
+ };
282
+ self.disarm_caps();
283
+ result.map_err(|e| call_err(&ruby, e))
284
+ }
285
+
286
+ /// Execute one entrypoint dispatch (`__kobako_run`).
287
+ ///
288
+ /// Rebuilds the WASI context with the two-frame stdin protocol
289
+ /// (preamble + snippets; no user source frame — docs/wire-codec.md
290
+ /// § Invocation channels), copies +envelope+ bytes into guest linear
291
+ /// memory via `__kobako_alloc`, and calls `__kobako_run(env_ptr,
292
+ /// env_len)`. Per-invocation cap semantics match [`Instance::eval`].
293
+ /// Returns +Kobako::Wasm::Error+ ("alloc returned 0") when guest
294
+ /// allocation fails (docs/behavior.md E-31).
295
+ pub(crate) fn run(
296
+ &self,
297
+ preamble: RString,
298
+ snippets: RString,
299
+ envelope: RString,
300
+ ) -> Result<(), MagnusError> {
239
301
  let ruby = Ruby::get().expect("Ruby thread");
240
- let run = self
241
- .run
242
- .as_ref()
243
- .ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_run"))?;
244
- self.refresh_wasi(preamble, source)?;
302
+ let run = require_export(&ruby, self.run.as_ref(), "__kobako_run")?;
303
+ self.refresh_wasi(&[rstring_to_vec(preamble), rstring_to_vec(snippets)]);
304
+ let (env_ptr, env_len) = self.write_envelope(&ruby, envelope)?;
245
305
  self.prime_caps();
246
- let result = self.call_guest(run);
306
+ let result = {
307
+ let mut store_ref = self.store.borrow_mut();
308
+ run.call(store_ref.as_context_mut(), (env_ptr, env_len))
309
+ };
247
310
  self.disarm_caps();
248
- result.map_err(|e| run_call_err(&ruby, e))
311
+ result.map_err(|e| call_err(&ruby, e))
249
312
  }
250
313
 
251
314
  /// Return the stdout capture from the most recent run as a Ruby
@@ -282,15 +345,61 @@ impl Instance {
282
345
  Ok(ruby.str_from_slice(&bytes))
283
346
  }
284
347
 
348
+ /// Return the docs/behavior.md B-35 per-last-invocation usage as a
349
+ /// Ruby 2-tuple `[wall_time, memory_peak]`. The element order
350
+ /// matches the `Kobako::Usage` field order declared in
351
+ /// `lib/kobako/usage.rb`; reorder both sides together if the field
352
+ /// list ever grows.
353
+ ///
354
+ /// * `wall_time` (Float seconds) — the wall-clock duration the
355
+ /// most recent invocation spent inside the guest export call.
356
+ /// Bracket opens in [`Instance::prime_caps`] and closes in
357
+ /// [`Instance::disarm_caps`], so the value mirrors the
358
+ /// `timeout` deadline accounting and excludes everything that
359
+ /// runs after the guest export returns — the post-export
360
+ /// `OUTCOME_BUFFER` fetch and decode, plus stdout / stderr
361
+ /// capture readout. `0.0` before the first invocation.
362
+ /// * `memory_peak` (Integer bytes) — the high-water mark of the
363
+ /// per-invocation `memory.grow` delta past the linear-memory
364
+ /// size captured at invocation entry. `0` before the first
365
+ /// invocation.
366
+ ///
367
+ /// Packing both readers into one ext call mirrors the
368
+ /// [`Instance::stdout`] / [`Instance::stderr`] pattern: one
369
+ /// `store.borrow()` per readout and a single magnus binding to
370
+ /// extend when B-35's field list grows past two.
371
+ pub(crate) fn usage(&self) -> Result<RArray, MagnusError> {
372
+ let ruby = Ruby::get().expect("Ruby thread");
373
+ let state = self.store.borrow();
374
+ let data = state.data();
375
+ let arr = ruby.ary_new_capa(2);
376
+ arr.push(data.wall_time().as_secs_f64())?;
377
+ arr.push(data.memory_peak())?;
378
+ Ok(arr)
379
+ }
380
+
285
381
  // -----------------------------------------------------------------
286
382
  // Private helpers.
287
383
  // -----------------------------------------------------------------
288
384
 
289
- /// Stamp the per-run wall-clock deadline into [`HostState`] and prime
290
- /// the wasmtime epoch deadline so the next ticker tick wakes the
291
- /// epoch-deadline callback. When `timeout` is disabled, the deadline
292
- /// is set far enough in the future that the callback effectively
293
- /// never fires.
385
+ /// Stamp the per-invocation wall-clock deadline into [`HostState`]
386
+ /// and prime the wasmtime epoch deadline so the next ticker tick
387
+ /// wakes the epoch-deadline callback. When `timeout` is disabled,
388
+ /// the deadline is set far enough in the future that the callback
389
+ /// effectively never fires.
390
+ ///
391
+ /// Also captures the current linear-memory size as the baseline
392
+ /// for the docs/behavior.md E-20 per-invocation memory delta cap.
393
+ /// The mruby image's declared initial allocation and the high-water
394
+ /// mark left by prior invocations on the same Sandbox are folded
395
+ /// into the baseline rather than the budget — only `memory.grow`
396
+ /// past +baseline+ counts against `memory_limit`.
397
+ ///
398
+ /// Also stamps the wall-clock entry instant for the
399
+ /// docs/behavior.md B-35 `wall_time` measurement. The bracket
400
+ /// closes in [`Instance::disarm_caps`] so it matches the
401
+ /// `timeout` deadline window and excludes `OUTCOME_BUFFER`
402
+ /// decoding and stdout / stderr capture readout.
294
403
  fn prime_caps(&self) {
295
404
  let mut store_ref = self.store.borrow_mut();
296
405
  match self.timeout {
@@ -304,52 +413,80 @@ impl Instance {
304
413
  store_ref.set_epoch_deadline(u64::MAX);
305
414
  }
306
415
  }
307
- store_ref.data_mut().limiter_mut().activate();
416
+ let baseline = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
417
+ Some(Extern::Memory(m)) => m.data_size(store_ref.as_context_mut()),
418
+ _ => 0,
419
+ };
420
+ store_ref.data_mut().arm_memory_cap(baseline);
421
+ store_ref.data_mut().start_wall_clock();
308
422
  }
309
423
 
310
424
  /// Drop the memory cap as soon as the guest call returns so that
311
425
  /// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
312
426
  /// which can grow guest memory transiently) is not attributed to
313
- /// the user script. Paired with [`Instance::prime_caps`].
427
+ /// the user script. Also closes the docs/behavior.md B-35
428
+ /// `wall_time` bracket opened by [`Instance::prime_caps`]. Paired
429
+ /// with [`Instance::prime_caps`].
314
430
  fn disarm_caps(&self) {
315
- self.store
316
- .borrow_mut()
317
- .data_mut()
318
- .limiter_mut()
319
- .deactivate();
431
+ let mut store_ref = self.store.borrow_mut();
432
+ store_ref.data_mut().stop_wall_clock();
433
+ store_ref.data_mut().disarm_memory_cap();
320
434
  }
321
435
 
322
- /// Invoke the cached `__kobako_run` TypedFunc against the live
323
- /// Store. Lives in its own helper so [`Instance::run`] reads as
324
- /// the run-path outline (export check refresh WASI → prime caps
325
- /// call guest disarm caps map errors) without the
326
- /// `RefCell::borrow_mut` boilerplate inline.
327
- fn call_guest(&self, run: &TypedFunc<(), ()>) -> wasmtime::Result<()> {
436
+ /// Allocate a +len+-byte buffer in guest linear memory via
437
+ /// `__kobako_alloc`, copy +envelope+ into it, and return +(ptr, len)+
438
+ /// as +i32+ values matching the `__kobako_run(env_ptr, env_len)` ABI.
439
+ /// Raises +Kobako::Wasm::Error+ when the guest export is missing or
440
+ /// allocation fails (docs/behavior.md E-31).
441
+ fn write_envelope(&self, ruby: &Ruby, envelope: RString) -> Result<(i32, i32), MagnusError> {
442
+ let bytes = rstring_to_vec(envelope);
443
+ let len_i32 = envelope_len_to_i32(bytes.len()).map_err(|msg| wasm_err(ruby, msg))?;
444
+
328
445
  let mut store_ref = self.store.borrow_mut();
329
- run.call(store_ref.as_context_mut(), ())
330
- }
331
-
332
- /// Rebuild the WASI context with fresh stdin (two-frame: preamble then
333
- /// source) plus fresh stdout/stderr pipes. Called at the top of every
334
- /// `#run`. Each pipe is sized at `cap + 1` so [`Instance::stdout`] /
335
- /// [`Instance::stderr`] can distinguish "wrote exactly cap bytes"
336
- /// from "exceeded cap"; uncapped channels fall back to `usize::MAX`
337
- /// and rely on `memory_limit` (E-20) for the real ceiling.
338
- fn refresh_wasi(&self, preamble: RString, source: RString) -> Result<(), MagnusError> {
339
- // SAFETY: `as_slice` borrows are scoped to building the stdin Vec
340
- // below — no Ruby allocations happen between the borrow and the
341
- // copy, so the underlying RString cannot move.
342
- let preamble_bytes: &[u8] = unsafe { preamble.as_slice() };
343
- let source_bytes: &[u8] = unsafe { source.as_slice() };
344
-
345
- let mut stdin_content: Vec<u8> =
346
- Vec::with_capacity(4 + preamble_bytes.len() + 4 + source_bytes.len());
347
- // Frame 1 — preamble
348
- stdin_content.extend_from_slice(&(preamble_bytes.len() as u32).to_be_bytes());
349
- stdin_content.extend_from_slice(preamble_bytes);
350
- // Frame 2 — user script
351
- stdin_content.extend_from_slice(&(source_bytes.len() as u32).to_be_bytes());
352
- stdin_content.extend_from_slice(source_bytes);
446
+ let alloc: TypedFunc<u32, u32> = self
447
+ .inner
448
+ .get_typed_func(store_ref.as_context_mut(), "__kobako_alloc")
449
+ .map_err(|_| wasm_err(ruby, SANDBOX_RUNTIME_MISSING_HOOKS))?;
450
+ let ptr = alloc
451
+ .call(store_ref.as_context_mut(), bytes.len() as u32)
452
+ .map_err(|e| wasm_err(ruby, format!("failed to allocate input buffer: {}", e)))?;
453
+ if ptr == 0 {
454
+ return Err(wasm_err(
455
+ ruby,
456
+ "could not allocate input buffer (out of memory)",
457
+ ));
458
+ }
459
+
460
+ let memory: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
461
+ Some(Extern::Memory(m)) => m,
462
+ _ => return Err(wasm_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO)),
463
+ };
464
+ let data = memory.data_mut(store_ref.as_context_mut());
465
+ let range = guest_buffer_range(ptr as usize, bytes.len(), data.len())
466
+ .map_err(|msg| wasm_err(ruby, msg))?;
467
+ data[range].copy_from_slice(&bytes);
468
+
469
+ Ok((ptr as i32, len_i32))
470
+ }
471
+
472
+ /// Rebuild the WASI context with fresh stdin (carrying every frame in
473
+ /// +frames+, each prefixed by its 4-byte big-endian u32 length —
474
+ /// docs/wire-codec.md § Invocation channels) plus fresh stdout / stderr
475
+ /// pipes. Called at the top of every guest invocation: +#eval+ passes
476
+ /// three frames (preamble, source, snippets), +#run+ passes two
477
+ /// (preamble, snippets — the invocation envelope arrives via linear
478
+ /// memory instead). Each output pipe is sized at `cap + 1` so
479
+ /// [`Instance::stdout`] / [`Instance::stderr`] can distinguish "wrote
480
+ /// exactly cap bytes" from "exceeded cap"; uncapped channels fall back
481
+ /// to `usize::MAX` and rely on `memory_limit` (docs/behavior.md E-20)
482
+ /// for the real ceiling.
483
+ fn refresh_wasi(&self, frames: &[Vec<u8>]) {
484
+ let total: usize = frames.iter().map(|f| 4 + f.len()).sum();
485
+ let mut stdin_content: Vec<u8> = Vec::with_capacity(total);
486
+ for frame in frames {
487
+ stdin_content.extend_from_slice(&(frame.len() as u32).to_be_bytes());
488
+ stdin_content.extend_from_slice(frame);
489
+ }
353
490
 
354
491
  let stdin_pipe = MemoryInputPipe::new(stdin_content);
355
492
  let stdout_pipe = MemoryOutputPipe::new(pipe_capacity(self.stdout_limit_bytes));
@@ -365,8 +502,6 @@ impl Instance {
365
502
  .borrow_mut()
366
503
  .data_mut()
367
504
  .install_wasi(wasi, stdout_pipe, stderr_pipe);
368
-
369
- Ok(())
370
505
  }
371
506
 
372
507
  /// Invoke `__kobako_take_outcome`, decode the packed +(ptr<<32)|len+
@@ -375,39 +510,94 @@ impl Instance {
375
510
  /// arithmetic overflows, the slice falls outside live memory, or the
376
511
  /// `memory` export itself is absent.
377
512
  fn fetch_outcome_bytes(&self, ruby: &Ruby) -> Result<Vec<u8>, MagnusError> {
378
- let take = self
379
- .take_outcome
380
- .as_ref()
381
- .ok_or_else(|| wasm_err(ruby, "guest does not export __kobako_take_outcome"))?;
513
+ let take = require_export(ruby, self.take_outcome.as_ref(), "__kobako_take_outcome")?;
382
514
 
383
515
  let mut store_ref = self.store.borrow_mut();
384
516
  let packed = take
385
517
  .call(store_ref.as_context_mut(), ())
386
- .map_err(|e| wasm_err(ruby, format!("__kobako_take_outcome(): {}", e)))?;
387
- let ptr = ((packed >> 32) & 0xffff_ffff) as usize;
388
- let len = (packed & 0xffff_ffff) as usize;
518
+ .map_err(|e| wasm_err(ruby, format!("failed to read invocation result: {}", e)))?;
519
+ let (ptr, len) = unpack_outcome_packed(packed);
389
520
 
390
521
  let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
391
522
  Some(Extern::Memory(m)) => m,
392
- _ => return Err(wasm_err(ruby, "guest does not export 'memory'")),
523
+ _ => return Err(wasm_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO)),
393
524
  };
394
525
  let data = mem.data(store_ref.as_context_mut());
395
- let end = ptr
396
- .checked_add(len)
397
- .ok_or_else(|| wasm_err(ruby, "outcome: ptr + len overflow"))?;
398
- if end > data.len() {
399
- return Err(wasm_err(
400
- ruby,
401
- format!(
402
- "outcome: range [{}, {}) exceeds memory size {}",
403
- ptr,
404
- end,
405
- data.len()
406
- ),
407
- ));
408
- }
409
- Ok(data[ptr..end].to_vec())
526
+ let range = guest_buffer_range(ptr, len, data.len()).map_err(|msg| {
527
+ wasm_err(ruby, format!("invocation result is out of bounds: {}", msg))
528
+ })?;
529
+ Ok(data[range].to_vec())
530
+ }
531
+ }
532
+
533
+ /// User-facing message for the "Sandbox runtime is missing one of the
534
+ /// internal Kobako hooks" failure mode. Phrased in caller vocabulary —
535
+ /// the underlying ABI symbol names (`__kobako_alloc`, `__kobako_eval`,
536
+ /// `__kobako_take_outcome`) are not actionable to callers, and the
537
+ /// gem itself raises this error so a self-reference like "matches the
538
+ /// kobako gem version" reads as third-person. The actionable
539
+ /// diagnosis is "your data/kobako.wasm is out of sync; rebuild it".
540
+ const SANDBOX_RUNTIME_MISSING_HOOKS: &str = "Sandbox runtime is missing required hooks; \
541
+ rebuild data/kobako.wasm against the installed version";
542
+
543
+ /// User-facing message for the "the loaded Wasm module is not a
544
+ /// Kobako-shaped runtime at all" failure mode (no linear memory
545
+ /// export). Same phrasing philosophy as
546
+ /// [`SANDBOX_RUNTIME_MISSING_HOOKS`].
547
+ const SANDBOX_RUNTIME_NOT_KOBAKO: &str = "Sandbox runtime does not export linear memory; \
548
+ this is not a Kobako-compatible Wasm module";
549
+
550
+ /// Return the cached +TypedFunc+ for an ABI export, or raise
551
+ /// +Kobako::Wasm::Error+ when the option is +None+. The run-path
552
+ /// methods (+#eval+, +#run+, +#outcome!+) all share the same
553
+ /// "missing export → Ruby error" boilerplate; this helper collapses
554
+ /// the three sites onto one safe entry. The +_name+ argument is
555
+ /// retained for future operator-side logging but is deliberately not
556
+ /// spliced into the user-facing message (see
557
+ /// [`SANDBOX_RUNTIME_MISSING_HOOKS`]).
558
+ fn require_export<'a, Params, Results>(
559
+ ruby: &Ruby,
560
+ export: Option<&'a TypedFunc<Params, Results>>,
561
+ _name: &str,
562
+ ) -> Result<&'a TypedFunc<Params, Results>, MagnusError>
563
+ where
564
+ Params: wasmtime::WasmParams,
565
+ Results: wasmtime::WasmResults,
566
+ {
567
+ export.ok_or_else(|| wasm_err(ruby, SANDBOX_RUNTIME_MISSING_HOOKS))
568
+ }
569
+
570
+ /// Validate the invocation envelope length and return it as +i32+ — the
571
+ /// signed wasm ABI parameter type for the guest-run entrypoint.
572
+ /// Rejects sizes above +i32::MAX+ (2 GiB) so the downstream cast cannot
573
+ /// silently wrap.
574
+ fn envelope_len_to_i32(len: usize) -> Result<i32, &'static str> {
575
+ i32::try_from(len).map_err(|_| "invocation payload exceeds 2 GiB")
576
+ }
577
+
578
+ /// Compute the half-open range `[ptr, ptr + len)` for a guest linear-memory
579
+ /// copy, validating that the arithmetic does not overflow and the range
580
+ /// fits inside `mem_size`. Shared by [`Instance::write_envelope`] (write
581
+ /// side) and [`Instance::fetch_outcome_bytes`] (read side).
582
+ fn guest_buffer_range(
583
+ ptr: usize,
584
+ len: usize,
585
+ mem_size: usize,
586
+ ) -> Result<core::ops::Range<usize>, &'static str> {
587
+ let end = ptr.checked_add(len).ok_or("ptr + len overflow")?;
588
+ if end > mem_size {
589
+ return Err("range exceeds Sandbox memory size");
410
590
  }
591
+ Ok(ptr..end)
592
+ }
593
+
594
+ /// Unpack the `(ptr, len)` u64 returned by `__kobako_take_outcome`:
595
+ /// high 32 bits = ptr, low 32 bits = len. Mirrors the guest-side
596
+ /// `crate::abi::unpack_u64` in `wasm/kobako-wasm/src/abi.rs`.
597
+ fn unpack_outcome_packed(packed: u64) -> (usize, usize) {
598
+ let ptr = (packed >> 32) as u32 as usize;
599
+ let len = packed as u32 as usize;
600
+ (ptr, len)
411
601
  }
412
602
 
413
603
  /// Translate a per-channel byte cap into the MemoryOutputPipe capacity:
@@ -446,6 +636,104 @@ fn capture_pair(ruby: &Ruby, raw: &[u8], cap: Option<usize>) -> Result<RArray, M
446
636
  Ok(arr)
447
637
  }
448
638
 
639
+ /// Epoch-deadline callback installed on every Store. Read the per-run
640
+ /// wall-clock deadline from [`HostState`] (docs/behavior.md B-01) and trap with
641
+ /// [`TimeoutTrap`] once the deadline has passed; otherwise extend the
642
+ /// next check by one tick of the process-wide epoch ticker. When the
643
+ /// deadline is `None` the callback should not fire under normal
644
+ /// `Instance::eval` / `Instance::run` flow because
645
+ /// `set_epoch_deadline(u64::MAX)` is used; returning a long extension
646
+ /// keeps the callback inert as a defence in depth.
647
+ fn epoch_deadline_callback(
648
+ ctx: StoreContextMut<'_, HostState>,
649
+ ) -> wasmtime::Result<UpdateDeadline> {
650
+ match ctx.data().deadline() {
651
+ Some(deadline) if Instant::now() >= deadline => Err(wasmtime::Error::new(TimeoutTrap)),
652
+ Some(_) => Ok(UpdateDeadline::Continue(1)),
653
+ None => Ok(UpdateDeadline::Continue(u64::MAX / 2)),
654
+ }
655
+ }
656
+
657
+ /// Configured-cap path classification for a wasmtime error. The
658
+ /// downcast logic stays in a pure helper so the
659
+ /// `Kobako::Wasm::TimeoutError` / `MemoryLimitError` /
660
+ /// `Kobako::Wasm::Error` mapping can be exercised from `cargo test`
661
+ /// without the magnus surface.
662
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
663
+ enum TrapClass {
664
+ /// docs/behavior.md E-19 wall-clock cap path.
665
+ Timeout,
666
+ /// docs/behavior.md E-20 linear-memory cap path.
667
+ MemoryLimit,
668
+ /// Any other wasmtime error — surfaces as the base
669
+ /// `Kobako::Wasm::Error`.
670
+ Other,
671
+ }
672
+
673
+ /// Inspect a wasmtime error to decide which `Kobako::Wasm::*` class it
674
+ /// should map to. Pure function — operates on the error's downcast
675
+ /// chain only, no magnus / Ruby state required.
676
+ fn classify_trap(err: &wasmtime::Error) -> TrapClass {
677
+ if err.downcast_ref::<TimeoutTrap>().is_some() {
678
+ TrapClass::Timeout
679
+ } else if err.downcast_ref::<MemoryLimitTrap>().is_some() {
680
+ TrapClass::MemoryLimit
681
+ } else {
682
+ TrapClass::Other
683
+ }
684
+ }
685
+
686
+ /// Map a wasmtime call error to the right `Kobako::Wasm::*` Ruby
687
+ /// exception class. The ABI export symbol (`__kobako_eval` /
688
+ /// `__kobako_run`) is deliberately omitted from the message — the
689
+ /// Sandbox layer attaches the user-facing verb (`Sandbox#eval` /
690
+ /// `Sandbox#run`) so the message reads in caller vocabulary rather
691
+ /// than ABI vocabulary.
692
+ ///
693
+ /// For the configured-cap paths ([`TrapClass::Timeout`] /
694
+ /// [`TrapClass::MemoryLimit`]) the trap's own [`std::fmt::Display`]
695
+ /// carries the user-facing reason (`"wall-clock deadline exceeded"`,
696
+ /// `"linear memory growth exceeded memory_limit: ..."`). The wasmtime
697
+ /// outer wrapper at `format!("{}", err)` would otherwise surface only
698
+ /// the `"error while executing at wasm backtrace: ..."` framing, which
699
+ /// is operator noise on a cap trap. For [`TrapClass::Other`] the
700
+ /// wasmtime wrapper IS the diagnostic (real script trap) so it stays.
701
+ fn call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
702
+ match classify_trap(&err) {
703
+ TrapClass::Timeout => {
704
+ let msg = err
705
+ .downcast_ref::<TimeoutTrap>()
706
+ .map(|t| t.to_string())
707
+ .unwrap_or_else(|| format!("{}", err));
708
+ timeout_err(ruby, msg)
709
+ }
710
+ TrapClass::MemoryLimit => {
711
+ let msg = err
712
+ .downcast_ref::<MemoryLimitTrap>()
713
+ .map(|t| t.to_string())
714
+ .unwrap_or_else(|| format!("{}", err));
715
+ memory_limit_err(ruby, msg)
716
+ }
717
+ TrapClass::Other => wasm_err(ruby, format!("{}", err)),
718
+ }
719
+ }
720
+
721
+ /// Map an instantiation error to the right `Kobako::Wasm::*` Ruby
722
+ /// exception. The memory cap is dormant during instantiation by design
723
+ /// (see [`HostState::arm_memory_cap`] / [`HostState::disarm_memory_cap`]),
724
+ /// but [`MemoryLimitTrap`] is still possible if a future Sandbox
725
+ /// configuration enables it during instantiation — keep the mapping
726
+ /// symmetric with [`call_err`]. [`TrapClass::Timeout`] is unreachable on
727
+ /// the instantiation path (the epoch deadline is not armed yet) but
728
+ /// folding it into the same `match` keeps the two paths visually paired.
729
+ fn instantiate_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
730
+ let msg = format!("instantiate: {}", err);
731
+ match classify_trap(&err) {
732
+ TrapClass::MemoryLimit => memory_limit_err(ruby, msg),
733
+ TrapClass::Timeout | TrapClass::Other => wasm_err(ruby, msg),
734
+ }
735
+ }
736
+
449
737
  #[cfg(test)]
450
738
  mod tests {
451
739
  //! Host-side unit tests for the pure capture helpers. The Ruby-
@@ -453,7 +741,11 @@ mod tests {
453
741
  //! allowlist excludes guest fd 2 writes); these tests pin the
454
742
  //! channel-agnostic slicing so a regression that only breaks one
455
743
  //! channel cannot sneak through.
456
- use super::{clip_capture, pipe_capacity};
744
+ use super::{
745
+ classify_trap, clip_capture, envelope_len_to_i32, guest_buffer_range, pipe_capacity,
746
+ unpack_outcome_packed, TrapClass,
747
+ };
748
+ use super::{MemoryLimitTrap, TimeoutTrap};
457
749
 
458
750
  #[test]
459
751
  fn pipe_capacity_adds_one_when_cap_is_set() {
@@ -508,49 +800,74 @@ mod tests {
508
800
  assert_eq!(bytes, b"");
509
801
  assert!(!truncated);
510
802
  }
511
- }
512
803
 
513
- /// Epoch-deadline callback installed on every Store. Read the per-run
514
- /// wall-clock deadline from [`HostState`] (SPEC.md B-01) and trap with
515
- /// [`TimeoutTrap`] once the deadline has passed; otherwise extend the
516
- /// next check by one tick of the process-wide epoch ticker. When the
517
- /// deadline is `None` the callback should not fire under normal
518
- /// `Instance::run` flow because `set_epoch_deadline(u64::MAX)` is used;
519
- /// returning a long extension keeps the callback inert as a defence in
520
- /// depth.
521
- fn epoch_deadline_callback(
522
- ctx: StoreContextMut<'_, HostState>,
523
- ) -> wasmtime::Result<UpdateDeadline> {
524
- match ctx.data().deadline() {
525
- Some(deadline) if Instant::now() >= deadline => Err(wasmtime::Error::new(TimeoutTrap)),
526
- Some(_) => Ok(UpdateDeadline::Continue(1)),
527
- None => Ok(UpdateDeadline::Continue(u64::MAX / 2)),
804
+ #[test]
805
+ fn envelope_len_to_i32_accepts_zero_and_max() {
806
+ assert_eq!(envelope_len_to_i32(0), Ok(0));
807
+ assert_eq!(envelope_len_to_i32(i32::MAX as usize), Ok(i32::MAX));
528
808
  }
529
- }
530
809
 
531
- /// Map a wasmtime call error to the right `Kobako::Wasm::*` Ruby
532
- /// exception class. `__kobako_run` traps are downcast to identify the
533
- /// configured-cap path (SPEC.md E-19 / E-20); everything else surfaces
534
- /// as the base `Kobako::Wasm::Error`.
535
- fn run_call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
536
- if err.downcast_ref::<TimeoutTrap>().is_some() {
537
- return timeout_err(ruby, format!("__kobako_run(): {}", err));
810
+ #[test]
811
+ fn envelope_len_to_i32_rejects_past_i32_max() {
812
+ assert!(envelope_len_to_i32(i32::MAX as usize + 1).is_err());
813
+ assert!(envelope_len_to_i32(usize::MAX).is_err());
538
814
  }
539
- if err.downcast_ref::<MemoryLimitTrap>().is_some() {
540
- return memory_limit_err(ruby, format!("__kobako_run(): {}", err));
815
+
816
+ #[test]
817
+ fn guest_buffer_range_returns_half_open_range() {
818
+ // Standard case: ptr + len fits inside memory.
819
+ assert_eq!(guest_buffer_range(10, 5, 100), Ok(10..15));
541
820
  }
542
- wasm_err(ruby, format!("__kobako_run(): {}", err))
543
- }
544
821
 
545
- /// Map an instantiation error to the right `Kobako::Wasm::*` Ruby
546
- /// exception. The memory cap is dormant during instantiation by design
547
- /// (see [`HostState::set_memory_cap_active`]), but [`MemoryLimitTrap`]
548
- /// is still possible if a future Sandbox configuration enables it
549
- /// during instantiation keep the mapping symmetric with
550
- /// [`run_call_err`].
551
- fn instantiate_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
552
- if err.downcast_ref::<MemoryLimitTrap>().is_some() {
553
- return memory_limit_err(ruby, format!("instantiate: {}", err));
822
+ #[test]
823
+ fn guest_buffer_range_accepts_zero_length_at_any_in_bounds_ptr() {
824
+ // Zero-length writes / reads must succeed as long as ptr is in
825
+ // bounds both reactor calls hand zero-length frames through
826
+ // (e.g. an empty Frame 3 snippets list).
827
+ assert_eq!(guest_buffer_range(0, 0, 0), Ok(0..0));
828
+ assert_eq!(guest_buffer_range(42, 0, 100), Ok(42..42));
829
+ }
830
+
831
+ #[test]
832
+ fn guest_buffer_range_rejects_ptr_plus_len_overflow() {
833
+ assert!(guest_buffer_range(usize::MAX, 1, usize::MAX).is_err());
834
+ }
835
+
836
+ #[test]
837
+ fn guest_buffer_range_rejects_end_past_memory() {
838
+ assert!(guest_buffer_range(10, 100, 50).is_err());
839
+ // End exactly equal to mem_size is in-bounds.
840
+ assert_eq!(guest_buffer_range(0, 50, 50), Ok(0..50));
841
+ }
842
+
843
+ #[test]
844
+ fn unpack_outcome_packed_extracts_high_ptr_low_len() {
845
+ assert_eq!(
846
+ unpack_outcome_packed(0xAABB_CCDD_1122_3344),
847
+ (0xAABB_CCDD, 0x1122_3344)
848
+ );
849
+ }
850
+
851
+ #[test]
852
+ fn unpack_outcome_packed_zero_decodes_to_zero_pair() {
853
+ assert_eq!(unpack_outcome_packed(0), (0, 0));
854
+ }
855
+
856
+ #[test]
857
+ fn classify_trap_routes_timeout_trap_to_timeout() {
858
+ let err = wasmtime::Error::new(TimeoutTrap);
859
+ assert_eq!(classify_trap(&err), TrapClass::Timeout);
860
+ }
861
+
862
+ #[test]
863
+ fn classify_trap_routes_memory_limit_trap_to_memory_limit() {
864
+ let err = wasmtime::Error::new(MemoryLimitTrap::new(1 << 20, 1 << 19));
865
+ assert_eq!(classify_trap(&err), TrapClass::MemoryLimit);
866
+ }
867
+
868
+ #[test]
869
+ fn classify_trap_falls_back_to_other_for_unknown_errors() {
870
+ let err = wasmtime::Error::msg("some other wasmtime fault");
871
+ assert_eq!(classify_trap(&err), TrapClass::Other);
554
872
  }
555
- wasm_err(ruby, format!("instantiate: {}", err))
556
873
  }