kobako 0.9.2 → 0.11.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +32 -0
  4. data/Cargo.lock +3 -1
  5. data/README.md +47 -19
  6. data/data/kobako.wasm +0 -0
  7. data/ext/kobako/Cargo.toml +12 -2
  8. data/ext/kobako/src/runtime/ambient.rs +1 -1
  9. data/ext/kobako/src/runtime/cache.rs +170 -6
  10. data/ext/kobako/src/runtime/capture.rs +1 -1
  11. data/ext/kobako/src/runtime/config.rs +3 -4
  12. data/ext/kobako/src/runtime/dispatch.rs +8 -8
  13. data/ext/kobako/src/runtime/exports.rs +32 -21
  14. data/ext/kobako/src/runtime/instance_pre.rs +97 -0
  15. data/ext/kobako/src/runtime/invocation.rs +36 -93
  16. data/ext/kobako/src/runtime/trap.rs +5 -5
  17. data/ext/kobako/src/runtime.rs +389 -403
  18. data/ext/kobako/src/snapshot.rs +2 -2
  19. data/lib/kobako/capture.rb +5 -7
  20. data/lib/kobako/catalog/handles.rb +28 -39
  21. data/lib/kobako/catalog/namespaces.rb +31 -20
  22. data/lib/kobako/catalog/snippets.rb +18 -16
  23. data/lib/kobako/codec/decoder.rb +5 -1
  24. data/lib/kobako/codec/utils.rb +6 -9
  25. data/lib/kobako/errors.rb +40 -36
  26. data/lib/kobako/handle.rb +2 -3
  27. data/lib/kobako/namespace.rb +17 -6
  28. data/lib/kobako/outcome.rb +12 -14
  29. data/lib/kobako/pool.rb +176 -0
  30. data/lib/kobako/sandbox.rb +68 -88
  31. data/lib/kobako/sandbox_options.rb +5 -9
  32. data/lib/kobako/snapshot.rb +2 -4
  33. data/lib/kobako/snippet/binary.rb +1 -3
  34. data/lib/kobako/snippet/source.rb +1 -2
  35. data/lib/kobako/snippet.rb +1 -2
  36. data/lib/kobako/transport/dispatcher.rb +39 -38
  37. data/lib/kobako/transport/request.rb +1 -1
  38. data/lib/kobako/transport/run.rb +23 -28
  39. data/lib/kobako/transport/yielder.rb +11 -17
  40. data/lib/kobako/transport.rb +2 -3
  41. data/lib/kobako/usage.rb +10 -13
  42. data/lib/kobako/version.rb +1 -1
  43. data/lib/kobako.rb +1 -0
  44. data/release-please-config.json +16 -1
  45. data/sig/kobako/catalog/handles.rbs +0 -2
  46. data/sig/kobako/errors.rbs +3 -0
  47. data/sig/kobako/namespace.rbs +2 -0
  48. data/sig/kobako/pool.rbs +44 -0
  49. data/sig/kobako/sandbox.rbs +2 -2
  50. data/sig/kobako/transport/dispatcher.rbs +2 -0
  51. metadata +4 -1
@@ -2,32 +2,38 @@
2
2
  //
3
3
  // The only Ruby-visible class is
4
4
  //
5
- // Kobako::Runtime — wraps wasmtime::Instance + cached TypedFuncs
5
+ // Kobako::Runtime — wraps a pre-linked InstancePre + per-Runtime caps
6
6
  //
7
7
  // constructed via `Kobako::Runtime.from_path(path, timeout, memory_limit,
8
- // stdout_limit, stderr_limit)`.
9
- // The underlying wasmtime Engine and compiled Module live in a process-scope
10
- // cache (see the `cache` submodule) and never surface to Ruby (SPEC.md "Code
11
- // Organization": `ext/` "exposes no Wasm engine types to the Host App or
12
- // downstream gems").
8
+ // stdout_limit, stderr_limit)`. Every invocation (`#eval` / `#run`)
9
+ // instantiates a fresh instance from the InstancePre and discards the
10
+ // whole Store afterwards the per-invocation instance discipline
11
+ // (ABI v2). The underlying wasmtime Engine and
12
+ // compiled Module live in a process-scope cache (see the `cache`
13
+ // submodule) and never surface to Ruby (SPEC.md "Code Organization":
14
+ // `ext/` "exposes no Wasm engine types to the Host App or downstream
15
+ // gems").
13
16
  //
14
17
  // Module layout (per CLAUDE.md principle #2 — one responsibility per file):
15
18
  //
16
19
  // * `cache` — process-wide Engine + per-path Module cache and the
17
20
  // process-singleton epoch ticker thread.
18
21
  // * `config` — per-Runtime caps (timeout / stdout / stderr limits).
19
- // * `exports` — cached `__kobako_eval` / `_run` / `_take_outcome` handles.
20
- // * `invocation` Invocation (per-Store context), StoreCell wrapper, the
21
- // `MemoryLimiter` memory cap, and the trap marker
22
- // types (`TimeoutTrap` / `MemoryLimitTrap`).
22
+ // * `exports` — per-invocation `__kobako_eval` / `_run` /
23
+ // `_take_outcome` / `_alloc` / `memory` handles.
24
+ // * `instance_pre`— host-import Linker wiring + per-path `InstancePre`
25
+ // cache.
26
+ // * `invocation` — Invocation (per-Store context), the `MemoryLimiter`
27
+ // memory cap, and the trap marker types
28
+ // (`TimeoutTrap` / `MemoryLimitTrap`).
23
29
  // * `dispatch` — `__kobako_dispatch` host-import dispatch helpers.
24
30
  // * `guest_mem` — Caller-based guest linear-memory alloc / write / read.
25
31
  // * `capture` — stdout / stderr pipe sizing + clip helpers.
26
32
  // * `trap` — wasmtime-error → `Kobako::*` trap classification.
27
33
  //
28
- // This file owns the `Kobako::Runtime` magnus class itself (the wasmtime
29
- // instance + Store + cached `Exports` + `Config`, plus the `#eval` /
30
- // `#run` run path), the Ruby error-class lazy-resolvers, the `trap_err` /
34
+ // This file owns the `Kobako::Runtime` magnus class itself (the
35
+ // InstancePre + `Config` + the per-invocation `#eval` / `#run` run
36
+ // path), the Ruby error-class lazy-resolvers, the `trap_err` /
31
37
  // `timeout_err` / `memory_limit_err` / `setup_err` constructors shared by
32
38
  // every submodule, and the Ruby init() that registers the class.
33
39
 
@@ -38,6 +44,7 @@ mod config;
38
44
  mod dispatch;
39
45
  mod exports;
40
46
  mod guest_mem;
47
+ mod instance_pre;
41
48
  mod invocation;
42
49
  mod trap;
43
50
 
@@ -54,23 +61,26 @@ use magnus::{gc, typed_data::DataTypeFunctions, value::Opaque, RArray, TypedData
54
61
 
55
62
  use crate::snapshot::Snapshot;
56
63
  use wasmtime::{
57
- AsContextMut, Caller, Extern, Instance as WtInstance, Linker, Memory, Module as WtModule,
58
- ResourceLimiter, Store as WtStore, TypedFunc,
64
+ AsContextMut, InstancePre as WtInstancePre, Memory, ResourceLimiter, Store as WtStore,
65
+ TypedFunc,
59
66
  };
60
- use wasmtime_wasi::p1;
61
67
  use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
62
68
  use wasmtime_wasi::WasiCtxBuilder;
63
69
 
64
- use self::cache::{cached_module, shared_engine};
70
+ use self::cache::shared_engine;
65
71
  use self::config::Config;
66
72
  use self::exports::Exports;
67
- use self::invocation::{Invocation, StoreCell};
73
+ use self::invocation::Invocation;
68
74
 
69
75
  /// The wire ABI version this host implements (docs/wire-codec.md § ABI
70
76
  /// Version). A Guest Binary is accepted only when its
71
- /// `__kobako_abi_version` export reports the same value (B-40 / E-42);
72
- /// the guest-side mirror is `kobako_core::abi::ABI_VERSION`.
73
- const ABI_VERSION: u32 = 1;
77
+ /// `__kobako_abi_version` export reports the same value; a mismatch
78
+ /// is a deterministic artifact fault. The guest-side mirror is
79
+ /// `kobako_core::abi::ABI_VERSION`. Version 2
80
+ /// carries the per-invocation instance discipline: the host
81
+ /// drives every invocation on a fresh instance, so the guest may leave
82
+ /// its VM state dirty at exit.
83
+ const ABI_VERSION: u32 = 2;
74
84
 
75
85
  /// Copy the bytes of `s` into a fresh `Vec<u8>`. Single safe entry to
76
86
  /// what would otherwise be an inline `unsafe { rstring.as_slice() }
@@ -140,22 +150,22 @@ pub(crate) fn trap_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
140
150
  /// Construct a `Kobako::SetupError` magnus error. Used for every
141
151
  /// construction-time failure on the `Runtime.from_path` path before any
142
152
  /// invocation runs — unreadable artifact, bytes that are not a valid Wasm
143
- /// module, or engine / linker / instantiation setup failure (docs/behavior.md
144
- /// E-41). The `ModuleNotBuiltError` subclass (artifact absent, E-40) is
153
+ /// module, or engine / linker / instantiation setup failure. The
154
+ /// `ModuleNotBuiltError` subclass (artifact absent) is
145
155
  /// raised through `MODULE_NOT_BUILT_ERROR` directly.
146
156
  pub(crate) fn setup_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
147
157
  error_in(ruby, &SETUP_ERROR, msg)
148
158
  }
149
159
 
150
160
  /// Construct a `Kobako::TimeoutError` magnus error. Surfaces the
151
- /// docs/behavior.md E-19 wall-clock cap path with the verb prefix added
161
+ /// wall-clock cap path with the verb prefix added
152
162
  /// by `Kobako::Sandbox#invoke!`.
153
163
  pub(crate) fn timeout_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
154
164
  error_in(ruby, &TIMEOUT_ERROR, msg)
155
165
  }
156
166
 
157
167
  /// Construct a `Kobako::MemoryLimitError` magnus error. Surfaces the
158
- /// docs/behavior.md E-20 linear-memory cap path with the verb prefix
168
+ /// linear-memory cap path with the verb prefix
159
169
  /// added by `Kobako::Sandbox#invoke!`.
160
170
  pub(crate) fn memory_limit_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
161
171
  error_in(ruby, &MEMORY_LIMIT_ERROR, msg)
@@ -164,8 +174,8 @@ pub(crate) fn memory_limit_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusErr
164
174
  /// Construct a `Kobako::SandboxError` magnus error. Used for the
165
175
  /// host-side pre-call faults the SPEC attributes to the sandbox / wire
166
176
  /// layer rather than the Wasm engine — currently the `#run` invocation
167
- /// envelope reservation failure (`__kobako_alloc` returns 0,
168
- /// docs/behavior.md E-31). The runtime is intact, so this must not be a
177
+ /// envelope reservation failure (`__kobako_alloc` returns 0).
178
+ /// The runtime is intact, so this must not be a
169
179
  /// `TrapError`: no discard-and-recreate recovery is owed to the caller.
170
180
  pub(crate) fn sandbox_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
171
181
  error_in(ruby, &SANDBOX_ERROR, msg)
@@ -200,26 +210,33 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
200
210
  #[derive(TypedData)]
201
211
  #[magnus(class = "Kobako::Runtime", free_immediately, size, mark)]
202
212
  pub(crate) struct Runtime {
203
- inner: WtInstance,
204
- store: StoreCell,
205
- // Cached host-driven ABI export handles (`__kobako_eval` / `_run` /
206
- // `_take_outcome`); see `Exports`. `__kobako_alloc` is not among them
207
- // — only `dispatch.rs` calls it, via `Caller::get_export`.
208
- exports: Exports,
213
+ // Pre-linked instantiation template (import wiring + type checks
214
+ // done once in `instance_pre::cached_instance_pre`). Every
215
+ // invocation instantiates a fresh instance from it and discards the
216
+ // whole Store afterwards the per-invocation instance discipline.
217
+ instance_pre: WtInstancePre<Invocation>,
218
+ // Per-invocation linear-memory cap,
219
+ // threaded into each fresh `Invocation`; lives apart from `Config`
220
+ // because the wasmtime `ResourceLimiter` callback consumes it from
221
+ // inside the wasm engine.
222
+ memory_limit: Option<usize>,
209
223
  // Wall-clock + per-channel capture caps forwarded from the Sandbox;
210
- // see `Config`. Distinct from the per-invocation `memory_limit`,
211
- // which lives on `Invocation` because the wasmtime `ResourceLimiter`
212
- // callback consumes it from inside the wasm engine.
224
+ // see `Config`.
213
225
  config: Config,
214
- // The host-side dispatch Proc (docs/behavior.md B-12), held here only
215
- // to give `DataTypeFunctions::mark` a Store-free read path so it can
216
- // pin the Proc across GC. The copy the `__kobako_dispatch` import
217
- // actually calls lives on `Invocation` (reached through
218
- // `Caller<Invocation>`, which cannot see this struct); see
219
- // `Runtime::set_on_dispatch`. Both hold the same `Copy` handle to the
220
- // one pinned Proc. `Cell` is sound under the GVL (see the `unsafe impl
226
+ // The host-side dispatch Proc, held here only
227
+ // to give `DataTypeFunctions::mark` a read path so it can pin the
228
+ // Proc across GC. The copy the `__kobako_dispatch` import actually
229
+ // calls is bound onto each per-invocation `Invocation` by
230
+ // `Runtime::new_store`. Both hold the same `Copy` handle to the one
231
+ // pinned Proc. `Cell` is sound under the GVL (see the `unsafe impl
221
232
  // Sync` below).
222
233
  on_dispatch: Cell<Option<Opaque<Value>>>,
234
+ // Usage of the most recent invocation —
235
+ // `(wall_time_seconds, memory_peak_bytes)` — captured by
236
+ // `build_snapshot` before the per-invocation Store is discarded so
237
+ // `#usage` reads survive the teardown. `(0.0, 0)` before the first
238
+ // invocation.
239
+ last_usage: Cell<(f64, usize)>,
223
240
  }
224
241
 
225
242
  impl DataTypeFunctions for Runtime {
@@ -228,7 +245,7 @@ impl DataTypeFunctions for Runtime {
228
245
  /// copy on `Invocation` for the duration of a guest invocation.
229
246
  /// `gc::Marker::mark` maps to `rb_gc_mark`, which pins: required because
230
247
  /// the Invocation copy is a cached `VALUE` that compaction would
231
- /// otherwise leave dangling (docs/behavior.md B-12 / B-13). Without
248
+ /// otherwise leave dangling. Without
232
249
  /// this the Proc has no GC root at all — sweep collects it (SIGSEGV on
233
250
  /// the next dispatch) and compaction relocates it (dispatch lands on
234
251
  /// the wrong receiver).
@@ -239,25 +256,24 @@ impl DataTypeFunctions for Runtime {
239
256
  }
240
257
  }
241
258
 
242
- // SAFETY: magnus requires `Send + Sync` on TypedData types. The added
243
- // `on_dispatch: Cell<…>` makes the auto-derived `Sync` unavailable, but the
244
- // same GVL invariant that justifies `StoreCell`'s assertion applies here:
245
- // every access to the Cell happens under the GVL on a single thread at a
246
- // time `set_on_dispatch` from a Ruby method call, and `mark` from a GC
247
- // pass that also holds the GVL. No cross-thread access to the Cell can
248
- // occur. `Send` stays auto-derived (`Opaque<Value>` is `Send`).
259
+ // SAFETY: magnus requires `Send + Sync` on TypedData types. The
260
+ // `on_dispatch` / `last_usage` `Cell`s make the auto-derived `Sync`
261
+ // unavailable, but every access to them happens under the GVL on a single
262
+ // thread at a time Ruby method calls, and a GC `mark` pass that also
263
+ // holds the GVL. No cross-thread access to either Cell can occur. `Send`
264
+ // stays auto-derived.
249
265
  unsafe impl Sync for Runtime {}
250
266
 
251
267
  impl Runtime {
252
- /// Construct an Runtime from a wasm file path, using the process-wide
253
- /// shared Engine and per-path Module cache. The single Ruby-facing
254
- /// constructor for `Kobako::Runtime` — Engine and Module are never
255
- /// visible to Ruby.
268
+ /// Construct a Runtime from a wasm file path, using the process-wide
269
+ /// shared Engine and per-path Module / InstancePre caches. The single
270
+ /// Ruby-facing constructor for `Kobako::Runtime` — Engine and Module
271
+ /// are never visible to Ruby.
256
272
  ///
257
- /// `timeout_seconds` is the docs/behavior.md B-01 wall-clock cap in seconds
273
+ /// `timeout_seconds` is the wall-clock cap in seconds
258
274
  /// (`None` disables); `memory_limit` is the linear-memory cap in
259
275
  /// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
260
- /// are the per-channel output caps (docs/behavior.md B-01 / B-04; `None`
276
+ /// are the per-channel output caps (`None`
261
277
  /// disables). All four are validated by the caller
262
278
  /// (`Kobako::Sandbox`); this method only refuses non-finite or
263
279
  /// non-positive timeouts as a defence in depth.
@@ -273,7 +289,7 @@ impl Runtime {
273
289
  None => None,
274
290
  Some(secs) if secs.is_finite() && secs > 0.0 => Some(Duration::from_secs_f64(secs)),
275
291
  Some(secs) => {
276
- // docs/behavior.md E-39: an invalid cap argument is a Host App
292
+ // An invalid cap argument is a Host App
277
293
  // programming error and raises `ArgumentError`, outside the
278
294
  // construction-failure `SetupError` branch. `SandboxOptions`
279
295
  // is the primary guard (it never lets a bad timeout reach
@@ -285,108 +301,39 @@ impl Runtime {
285
301
  }
286
302
  };
287
303
 
288
- let engine = shared_engine()?;
289
- let module = cached_module(Path::new(&path))?;
290
-
291
- let mut store = WtStore::new(engine, Invocation::new(memory_limit));
292
- store.limiter(|state: &mut Invocation| -> &mut dyn ResourceLimiter { state.limiter_mut() });
293
- store.epoch_deadline_callback(trap::epoch_deadline_callback);
294
-
295
- let store_cell = StoreCell::new(store);
296
- Self::build(
297
- engine,
298
- &module,
299
- store_cell,
300
- timeout,
301
- stdout_limit_bytes,
302
- stderr_limit_bytes,
303
- )
304
- }
305
-
306
- /// Build an `Runtime` from an engine, module, and store cell. The
307
- /// store cell is moved in and ends up owned by the returned Runtime.
308
- /// Wires the WASI p1 imports plus the `__kobako_dispatch` host import.
309
- fn build(
310
- engine: &wasmtime::Engine,
311
- module: &WtModule,
312
- store_cell: StoreCell,
313
- timeout: Option<Duration>,
314
- stdout_limit_bytes: Option<usize>,
315
- stderr_limit_bytes: Option<usize>,
316
- ) -> Result<Self, MagnusError> {
317
- let ruby = Ruby::get().expect("Ruby thread");
318
- let mut linker: Linker<Invocation> = Linker::new(engine);
319
-
320
- // Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2
321
- // to the MemoryOutputPipes set up before each run via
322
- // `Runtime::eval`. The closure pulls a `&mut WasiP1Ctx` out of
323
- // Invocation; the panic semantics live inside `Invocation::wasi_mut`
324
- // so the wiring stays honest about its precondition.
325
- p1::add_to_linker_sync(&mut linker, |state: &mut Invocation| state.wasi_mut())
326
- .map_err(|e| setup_err(&ruby, format!("failed to set up the WASI runtime: {}", e)))?;
327
-
328
- // `__kobako_dispatch` host import. Signature per docs/wire-codec.md
329
- // § ABI Signatures:
330
- // (req_ptr: i32, req_len: i32) -> i64
331
- // Decodes the Request bytes, dispatches via the Ruby-side
332
- // dispatch Proc (bound per-Sandbox through `Runtime#on_dispatch=`),
333
- // allocates a guest buffer through `__kobako_alloc`, writes
334
- // the Response bytes there, and returns the packed
335
- // `(ptr<<32)|len`. The dispatcher returns 0 on any wire-layer
336
- // fault (including no Proc bound); see `dispatch::handle`.
337
- linker
338
- .func_wrap(
339
- "env",
340
- "__kobako_dispatch",
341
- |mut caller: Caller<'_, Invocation>, req_ptr: i32, req_len: i32| -> i64 {
342
- dispatch::handle(&mut caller, req_ptr, req_len)
343
- },
344
- )
345
- .map_err(|e| {
346
- setup_err(
347
- &ruby,
348
- format!("failed to set up the host callback bridge: {}", e),
349
- )
350
- })?;
351
-
352
- let instance = {
353
- let mut store_ref = store_cell.borrow_mut();
354
- linker
355
- .instantiate(store_ref.as_context_mut(), module)
356
- .map_err(|e| trap::instantiate_err(&ruby, e))?
357
- };
358
-
359
- Self::validate_abi_version(&instance, &store_cell, &ruby)?;
360
-
361
- let exports = Exports::resolve(&instance, &store_cell);
362
-
363
- Ok(Self {
364
- inner: instance,
365
- store: store_cell,
366
- exports,
304
+ let runtime = Self {
305
+ instance_pre: instance_pre::cached_instance_pre(Path::new(&path))?,
306
+ memory_limit,
367
307
  config: Config {
368
308
  timeout,
369
309
  stdout_limit_bytes,
370
310
  stderr_limit_bytes,
371
311
  },
372
312
  on_dispatch: Cell::new(None),
373
- })
313
+ last_usage: Cell::new((0.0, 0)),
314
+ };
315
+ runtime.probe_abi_version(&ruby)?;
316
+ Ok(runtime)
374
317
  }
375
318
 
376
- /// Probe the guest's `__kobako_abi_version` export once at
377
- /// construction and require equality with `ABI_VERSION`
378
- /// (docs/behavior.md B-40). An absent export or a non-equal value is
379
- /// E-42 — a deterministic artifact fault raised as
380
- /// `Kobako::SetupError`.
381
- fn validate_abi_version(
382
- instance: &WtInstance,
383
- store: &StoreCell,
384
- ruby: &Ruby,
385
- ) -> Result<(), MagnusError> {
386
- let mut store_ref = store.borrow_mut();
387
- let mut ctx = store_ref.as_context_mut();
319
+ /// Instantiate a throwaway probe instance at construction and require
320
+ /// the guest's `__kobako_abi_version` export to equal `ABI_VERSION`
321
+ /// An absent export or a non-equal value is
322
+ /// a deterministic artifact fault raised as
323
+ /// `Kobako::SetupError`. The probe Store drops here; invocation
324
+ /// instances are created per `#eval` / `#run`. The frameless WASI
325
+ /// context keeps a third-party guest whose start section touches
326
+ /// WASI on the `SetupError` path instead of panicking in
327
+ /// `Invocation::wasi_mut`.
328
+ fn probe_abi_version(&self, ruby: &Ruby) -> Result<(), MagnusError> {
329
+ let mut store = self.new_store()?;
330
+ install_wasi_frames(&mut store, &self.config, &[])?;
331
+ let instance = self
332
+ .instance_pre
333
+ .instantiate(store.as_context_mut())
334
+ .map_err(|e| trap::instantiate_err(ruby, e))?;
388
335
  let probe = instance
389
- .get_typed_func::<(), u32>(&mut ctx, "__kobako_abi_version")
336
+ .get_typed_func::<(), u32>(store.as_context_mut(), "__kobako_abi_version")
390
337
  .map_err(|_| {
391
338
  setup_err(
392
339
  ruby,
@@ -396,7 +343,7 @@ impl Runtime {
396
343
  ),
397
344
  )
398
345
  })?;
399
- let reported = probe.call(&mut ctx, ()).map_err(|e| {
346
+ let reported = probe.call(store.as_context_mut(), ()).map_err(|e| {
400
347
  setup_err(
401
348
  ruby,
402
349
  format!("failed to read the Guest Binary's ABI version: {e}"),
@@ -415,31 +362,19 @@ impl Runtime {
415
362
  Ok(())
416
363
  }
417
364
 
418
- /// Register the Ruby-side dispatch `Proc` on the active Invocation.
419
- /// Bound to Ruby as `Kobako::Runtime#on_dispatch=`. From this point on,
420
- /// every `__kobako_dispatch` host import invocation calls the Proc
421
- /// with the request bytes and writes the returned Response bytes back
422
- /// into guest memory (docs/behavior.md B-12).
365
+ /// Register the Ruby-side dispatch `Proc`.
366
+ /// Bound to Ruby as `Kobako::Runtime#on_dispatch=`. The handle is
367
+ /// pinned by `DataTypeFunctions::mark` and copied onto every
368
+ /// per-invocation `Invocation` by `Runtime::new_store`, where the
369
+ /// `__kobako_dispatch` import reads it through `Caller<Invocation>`.
423
370
  pub(crate) fn set_on_dispatch(&self, proc_value: Value) -> Result<(), MagnusError> {
424
- let on_dispatch = Opaque::from(proc_value);
425
- // Write both copies of the one Proc handle: the `on_dispatch` Cell
426
- // gives `DataTypeFunctions::mark` a Store-free read path to pin the
427
- // Proc across GC, and the `Invocation` copy is what the
428
- // `__kobako_dispatch` import reads through `Caller<Invocation>`.
429
- // `mark` cannot reach the Invocation copy itself — the Store is held
430
- // `borrow_mut` for the whole guest call, exactly when GC may fire
431
- // during dispatch — so the Cell is the dedicated GC-rooting anchor.
432
- self.on_dispatch.set(Some(on_dispatch));
433
- self.store
434
- .borrow_mut()
435
- .data_mut()
436
- .bind_on_dispatch(on_dispatch);
371
+ self.on_dispatch.set(Some(Opaque::from(proc_value)));
437
372
  Ok(())
438
373
  }
439
374
 
440
375
  /// Synchronously re-enter the guest's `__kobako_yield_to_block`
441
376
  /// export with `args_bytes` as the yield-arguments payload, and
442
- /// return the YieldResponse bytes the guest produced (B-24).
377
+ /// return the YieldResponse bytes the guest produced.
443
378
  ///
444
379
  /// Bound to Ruby as `Kobako::Runtime#yield_to_active_invocation`.
445
380
  /// Recovers the dispatcher's `&mut Caller` from the per-thread
@@ -447,7 +382,7 @@ impl Runtime {
447
382
  /// already inside a `__kobako_dispatch` callback, so the Caller
448
383
  /// parked on the Rust stack is the same one the Sandbox-level
449
384
  /// `#eval` / `#run` is driving. Invoked from the host-side yield
450
- /// proxy that the dispatcher hands to Service methods (B-23 / B-24);
385
+ /// proxy that the dispatcher hands to Service methods;
451
386
  /// raises `Kobako::TrapError` when called outside an active dispatch
452
387
  /// frame, or when any of the underlying allocation / write / call /
453
388
  /// read steps fails.
@@ -457,7 +392,7 @@ impl Runtime {
457
392
  ) -> Result<RString, MagnusError> {
458
393
  let ruby = Ruby::get().expect("Ruby thread");
459
394
  let _ = self; // The Caller carries its own Store; `self` is only
460
- // a marker that the method belongs to an Runtime.
395
+ // a marker that the method belongs to a Runtime.
461
396
 
462
397
  let bytes = rstring_to_vec(args_bytes);
463
398
  let Some(caller) = dispatch::current_caller() else {
@@ -482,19 +417,19 @@ impl Runtime {
482
417
  /// Execute one guest invocation (`__kobako_eval` — one-shot source)
483
418
  /// and return a `Snapshot` bundling every per-invocation observable.
484
419
  ///
485
- /// Rebuilds the WASI context with fresh stdin / stdout / stderr pipes
486
- /// (the three-frame stdin protocol carries `preamble`, `source`, then
487
- /// `snippets` — docs/wire-codec.md § Invocation channels), then
488
- /// invokes `__kobako_eval`. Per-invocation caps (docs/behavior.md
489
- /// B-01) are primed here: the wall-clock deadline is stamped into
490
- /// `Invocation` and the epoch deadline is set to fire at the next
491
- /// ticker tick; the memory-cap limiter is already wired.
420
+ /// Builds a fresh Store + instance whose WASI context carries
421
+ /// the three-frame stdin protocol (`preamble`, `source`, `snippets`
422
+ /// — docs/wire-codec.md § Invocation channels), then invokes
423
+ /// `__kobako_eval`. Per-invocation caps are
424
+ /// primed here: the wall-clock deadline is stamped into `Invocation`
425
+ /// and the epoch deadline is set to fire at the next ticker tick;
426
+ /// the memory-cap limiter is already wired.
492
427
  ///
493
428
  /// On a wasmtime trap the configured-cap path raises
494
429
  /// `Kobako::TimeoutError` / `Kobako::MemoryLimitError`; everything
495
430
  /// else raises `Kobako::TrapError`. On success the Snapshot carries
496
431
  /// the OUTCOME_BUFFER bytes, the per-channel stdout / stderr captures
497
- /// with their truncation flags, and the B-35 usage figures.
432
+ /// with their truncation flags, and the usage figures.
498
433
  pub(crate) fn eval(
499
434
  &self,
500
435
  preamble: RString,
@@ -502,27 +437,33 @@ impl Runtime {
502
437
  snippets: RString,
503
438
  ) -> Result<Snapshot, MagnusError> {
504
439
  let ruby = Ruby::get().expect("Ruby thread");
505
- let eval = require_export(&ruby, self.exports.eval.as_ref())?;
506
- self.refresh_wasi(&[
507
- rstring_to_vec(preamble),
508
- rstring_to_vec(source),
509
- rstring_to_vec(snippets),
510
- ])?;
511
- self.call_with_caps(eval, ())
440
+ let mut store = self.new_store()?;
441
+ install_wasi_frames(
442
+ &mut store,
443
+ &self.config,
444
+ &[
445
+ rstring_to_vec(preamble),
446
+ rstring_to_vec(source),
447
+ rstring_to_vec(snippets),
448
+ ],
449
+ )?;
450
+ let exports = self.instantiate(&ruby, &mut store)?;
451
+ let eval = require_export(&ruby, exports.eval.as_ref())?;
452
+ self.call_with_caps(&mut store, &exports, eval, ())
512
453
  .map_err(|e| trap::call_err(&ruby, e))?;
513
- self.build_snapshot(&ruby)
454
+ self.build_snapshot(&ruby, &mut store, &exports)
514
455
  }
515
456
 
516
457
  /// Execute one entrypoint dispatch (`__kobako_run`) and return a
517
458
  /// `Snapshot` bundling every per-invocation observable.
518
459
  ///
519
- /// Rebuilds the WASI context with the two-frame stdin protocol
520
- /// (preamble + snippets; no user source frame — docs/wire-codec.md
521
- /// § Invocation channels), copies `envelope` bytes into guest linear
522
- /// memory via `__kobako_alloc`, and calls `__kobako_run(env_ptr,
523
- /// env_len)`. Per-invocation cap semantics match `Runtime::eval`.
524
- /// Raises `Kobako::TrapError` ("alloc returned 0") when guest
525
- /// allocation fails (docs/behavior.md E-31).
460
+ /// Builds a fresh Store + instance whose WASI context carries
461
+ /// the two-frame stdin protocol (preamble + snippets; no user source
462
+ /// frame — docs/wire-codec.md § Invocation channels), copies
463
+ /// `envelope` bytes into guest linear memory via `__kobako_alloc`,
464
+ /// and calls `__kobako_run(env_ptr, env_len)`. Per-invocation cap
465
+ /// semantics match `Runtime::eval`. Raises `Kobako::TrapError`
466
+ /// ("alloc returned 0") when guest allocation fails.
526
467
  pub(crate) fn run(
527
468
  &self,
528
469
  preamble: RString,
@@ -530,49 +471,21 @@ impl Runtime {
530
471
  envelope: RString,
531
472
  ) -> Result<Snapshot, MagnusError> {
532
473
  let ruby = Ruby::get().expect("Ruby thread");
533
- let run = require_export(&ruby, self.exports.run.as_ref())?;
534
- self.refresh_wasi(&[rstring_to_vec(preamble), rstring_to_vec(snippets)])?;
535
- let (env_ptr, env_len) = self.write_envelope(&ruby, envelope)?;
536
- self.call_with_caps(run, (env_ptr, env_len))
474
+ let mut store = self.new_store()?;
475
+ install_wasi_frames(
476
+ &mut store,
477
+ &self.config,
478
+ &[rstring_to_vec(preamble), rstring_to_vec(snippets)],
479
+ )?;
480
+ let exports = self.instantiate(&ruby, &mut store)?;
481
+ let run = require_export(&ruby, exports.run.as_ref())?;
482
+ let (env_ptr, env_len) = write_envelope(&ruby, &mut store, &exports, envelope)?;
483
+ self.call_with_caps(&mut store, &exports, run, (env_ptr, env_len))
537
484
  .map_err(|e| trap::call_err(&ruby, e))?;
538
- self.build_snapshot(&ruby)
539
- }
540
-
541
- /// Collect every per-invocation observable into a fresh `Snapshot`.
542
- /// Called from the run-path methods after the guest export returns
543
- /// successfully: drains OUTCOME_BUFFER via `__kobako_take_outcome`,
544
- /// snapshots the per-channel stdout / stderr pipes (clipped to their
545
- /// caps), and reads B-35 `wall_time` / `memory_peak` from Invocation.
546
- fn build_snapshot(&self, ruby: &Ruby) -> Result<Snapshot, MagnusError> {
547
- let return_bytes = self.fetch_outcome_bytes(ruby)?;
548
- let (stdout_raw, stderr_raw, wall_time, memory_peak) = {
549
- let state = self.store.borrow();
550
- let data = state.data();
551
- (
552
- data.stdout_bytes(),
553
- data.stderr_bytes(),
554
- data.wall_time(),
555
- data.memory_peak(),
556
- )
557
- };
558
- let (stdout_visible, stdout_truncated) =
559
- capture::clip_capture(&stdout_raw, self.config.stdout_limit_bytes);
560
- let stdout_bytes = stdout_visible.to_vec();
561
- let (stderr_visible, stderr_truncated) =
562
- capture::clip_capture(&stderr_raw, self.config.stderr_limit_bytes);
563
- let stderr_bytes = stderr_visible.to_vec();
564
- Ok(Snapshot::new(
565
- return_bytes,
566
- stdout_bytes,
567
- stdout_truncated,
568
- stderr_bytes,
569
- stderr_truncated,
570
- wall_time,
571
- memory_peak,
572
- ))
485
+ self.build_snapshot(&ruby, &mut store, &exports)
573
486
  }
574
487
 
575
- /// Return the docs/behavior.md B-35 per-last-invocation usage as a
488
+ /// Return the per-last-invocation usage as a
576
489
  /// Ruby 2-tuple `[wall_time, memory_peak]`. The element order
577
490
  /// matches the `Kobako::Usage` field order declared in
578
491
  /// `lib/kobako/usage.rb`; reorder both sides together if the field
@@ -581,27 +494,22 @@ impl Runtime {
581
494
  /// * `wall_time` (Float seconds) — the wall-clock duration the
582
495
  /// most recent invocation spent inside the guest export call.
583
496
  /// Bracket opens in `Runtime::prime_caps` and closes in
584
- /// `Runtime::disarm_caps`, so the value mirrors the
585
- /// `timeout` deadline accounting and excludes everything that
586
- /// runs after the guest export returns the post-export
587
- /// `OUTCOME_BUFFER` fetch and decode, plus stdout / stderr
588
- /// capture readout. `0.0` before the first invocation.
497
+ /// `disarm_caps`, so the value mirrors the `timeout` deadline
498
+ /// accounting and excludes everything that runs after the guest
499
+ /// export returns. `0.0` before the first invocation.
589
500
  /// * `memory_peak` (Integer bytes) — the high-water mark of the
590
501
  /// per-invocation `memory.grow` delta past the linear-memory
591
502
  /// size captured at invocation entry. `0` before the first
592
503
  /// invocation.
593
504
  ///
594
- /// Packing both readers into one ext call mirrors the combined
595
- /// stdout / stderr readout in `Runtime::build_snapshot`: one
596
- /// `store.borrow()` per readout and a single magnus binding to
597
- /// extend when B-35's field list grows past two.
505
+ /// Reads the `last_usage` Cell `build_snapshot` populated before the
506
+ /// per-invocation Store was discarded.
598
507
  pub(crate) fn usage(&self) -> Result<RArray, MagnusError> {
599
508
  let ruby = Ruby::get().expect("Ruby thread");
600
- let state = self.store.borrow();
601
- let data = state.data();
509
+ let (wall_time, memory_peak) = self.last_usage.get();
602
510
  let arr = ruby.ary_new_capa(2);
603
- arr.push(data.wall_time().as_secs_f64())?;
604
- arr.push(data.memory_peak())?;
511
+ arr.push(wall_time)?;
512
+ arr.push(memory_peak)?;
605
513
  Ok(arr)
606
514
  }
607
515
 
@@ -609,17 +517,54 @@ impl Runtime {
609
517
  // Private helpers.
610
518
  // -----------------------------------------------------------------
611
519
 
520
+ /// Build the per-invocation Store: a fresh `Invocation` wired with
521
+ /// the memory limiter, the epoch-deadline callback, and the
522
+ /// registered dispatch Proc.
523
+ fn new_store(&self) -> Result<WtStore<Invocation>, MagnusError> {
524
+ let mut store = WtStore::new(shared_engine()?, Invocation::new(self.memory_limit));
525
+ store.limiter(|state: &mut Invocation| -> &mut dyn ResourceLimiter { state.limiter_mut() });
526
+ store.epoch_deadline_callback(trap::epoch_deadline_callback);
527
+ if let Some(on_dispatch) = self.on_dispatch.get() {
528
+ store.data_mut().bind_on_dispatch(on_dispatch);
529
+ }
530
+ Ok(store)
531
+ }
532
+
533
+ /// Instantiate the per-invocation instance from the pre-linked
534
+ /// template and resolve its host-driven export handles. An
535
+ /// instantiation failure at invocation time is an engine fault —
536
+ /// `Kobako::TrapError` — unlike the construction-time probe, whose
537
+ /// failure is `SetupError`.
538
+ fn instantiate(
539
+ &self,
540
+ ruby: &Ruby,
541
+ store: &mut WtStore<Invocation>,
542
+ ) -> Result<Exports, MagnusError> {
543
+ let instance = self
544
+ .instance_pre
545
+ .instantiate(store.as_context_mut())
546
+ .map_err(|e| {
547
+ trap_err(
548
+ ruby,
549
+ format!("failed to instantiate the Sandbox runtime: {e}"),
550
+ )
551
+ })?;
552
+ Ok(Exports::resolve(&instance, store.as_context_mut()))
553
+ }
554
+
612
555
  /// Run one guest export call inside the per-invocation cap window:
613
- /// `Runtime::prime_caps` before, `Runtime::disarm_caps` after —
614
- /// the shared bracket for both run-path exports (`__kobako_eval` /
556
+ /// `Runtime::prime_caps` before, `disarm_caps` after — the shared
557
+ /// bracket for both run-path exports (`__kobako_eval` /
615
558
  /// `__kobako_run`). Disarm runs whether the call returns or traps, so
616
- /// the docs/behavior.md B-35 `wall_time` bracket and the E-20 memory
559
+ /// the `wall_time` bracket and the memory
617
560
  /// cap always close — that close-on-trap guarantee is the reason this
618
561
  /// bracket lives in one place rather than inline at each call site.
619
562
  /// The wasmtime trap is returned unmapped; each caller wraps it
620
563
  /// through `trap::call_err` for its own error context.
621
564
  fn call_with_caps<Params, Results>(
622
565
  &self,
566
+ store: &mut WtStore<Invocation>,
567
+ exports: &Exports,
623
568
  export: &TypedFunc<Params, Results>,
624
569
  params: Params,
625
570
  ) -> Result<Results, wasmtime::Error>
@@ -627,12 +572,15 @@ impl Runtime {
627
572
  Params: wasmtime::WasmParams,
628
573
  Results: wasmtime::WasmResults,
629
574
  {
630
- self.prime_caps();
631
- let result = {
632
- let mut store_ref = self.store.borrow_mut();
633
- export.call(store_ref.as_context_mut(), params)
634
- };
635
- self.disarm_caps();
575
+ self.prime_caps(store, exports);
576
+ let result = export.call(store.as_context_mut(), params);
577
+ disarm_caps(store);
578
+ // Stash the usage figures on every outcome — including the
579
+ // trap paths, where `build_snapshot` never runs and the Store is
580
+ // about to be discarded with the error.
581
+ let data = store.data();
582
+ self.last_usage
583
+ .set((data.wall_time().as_secs_f64(), data.memory_peak()));
636
584
  result
637
585
  }
638
586
 
@@ -643,173 +591,211 @@ impl Runtime {
643
591
  /// effectively never fires.
644
592
  ///
645
593
  /// Also captures the current linear-memory size as the baseline
646
- /// for the docs/behavior.md E-20 per-invocation memory delta cap.
647
- /// The mruby image's declared initial allocation and the high-water
648
- /// mark left by prior invocations on the same Sandbox are folded
649
- /// into the baseline rather than the budget — only `memory.grow`
650
- /// past `baseline` counts against `memory_limit`.
651
- ///
652
- /// Also stamps the wall-clock entry instant for the
653
- /// docs/behavior.md B-35 `wall_time` measurement. The bracket
654
- /// closes in `Runtime::disarm_caps` so it matches the
655
- /// `timeout` deadline window and excludes `OUTCOME_BUFFER`
594
+ /// for the per-invocation memory delta cap
595
+ /// the pre-initialized image's allocation is folded into the
596
+ /// baseline rather than the budget and stamps the wall-clock
597
+ /// entry instant for the `wall_time`
598
+ /// measurement. The bracket closes in `disarm_caps` so it matches
599
+ /// the `timeout` deadline window and excludes `OUTCOME_BUFFER`
656
600
  /// decoding and stdout / stderr capture readout.
657
- fn prime_caps(&self) {
658
- let mut store_ref = self.store.borrow_mut();
601
+ fn prime_caps(&self, store: &mut WtStore<Invocation>, exports: &Exports) {
659
602
  match self.config.timeout {
660
603
  Some(timeout) => {
661
604
  let deadline = Instant::now() + timeout;
662
- store_ref.data_mut().set_deadline(Some(deadline));
663
- store_ref.set_epoch_deadline(1);
605
+ store.data_mut().set_deadline(Some(deadline));
606
+ store.set_epoch_deadline(1);
664
607
  }
665
608
  None => {
666
- store_ref.data_mut().set_deadline(None);
667
- store_ref.set_epoch_deadline(u64::MAX);
609
+ store.data_mut().set_deadline(None);
610
+ store.set_epoch_deadline(u64::MAX);
668
611
  }
669
612
  }
670
- let baseline = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
671
- Some(Extern::Memory(m)) => m.data_size(store_ref.as_context_mut()),
672
- _ => 0,
613
+ let baseline = match exports.memory {
614
+ Some(m) => m.data_size(store.as_context_mut()),
615
+ None => 0,
673
616
  };
674
- store_ref.data_mut().arm_memory_cap(baseline);
675
- store_ref.data_mut().start_wall_clock();
617
+ store.data_mut().arm_memory_cap(baseline);
618
+ store.data_mut().start_wall_clock();
676
619
  }
677
620
 
678
- /// Drop the memory cap as soon as the guest call returns so that
679
- /// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
680
- /// which can grow guest memory transiently) is not attributed to
681
- /// the user script. Also closes the docs/behavior.md B-35
682
- /// `wall_time` bracket opened by `Runtime::prime_caps`. Paired
683
- /// with `Runtime::prime_caps`.
684
- fn disarm_caps(&self) {
685
- let mut store_ref = self.store.borrow_mut();
686
- store_ref.data_mut().stop_wall_clock();
687
- store_ref.data_mut().disarm_memory_cap();
621
+ /// Collect every per-invocation observable into a fresh `Snapshot`.
622
+ /// Called from the run-path methods after the guest export returns
623
+ /// successfully: drains OUTCOME_BUFFER via `__kobako_take_outcome`
624
+ /// and snapshots the per-channel stdout / stderr pipes (clipped to
625
+ /// their caps). The usage figures were already stashed by
626
+ /// `call_with_caps`.
627
+ fn build_snapshot(
628
+ &self,
629
+ ruby: &Ruby,
630
+ store: &mut WtStore<Invocation>,
631
+ exports: &Exports,
632
+ ) -> Result<Snapshot, MagnusError> {
633
+ let return_bytes = fetch_outcome_bytes(ruby, store, exports)?;
634
+ let data = store.data();
635
+ let (stdout_raw, stderr_raw, wall_time, memory_peak) = (
636
+ data.stdout_bytes(),
637
+ data.stderr_bytes(),
638
+ data.wall_time(),
639
+ data.memory_peak(),
640
+ );
641
+ let (stdout_visible, stdout_truncated) =
642
+ capture::clip_capture(&stdout_raw, self.config.stdout_limit_bytes);
643
+ let stdout_bytes = stdout_visible.to_vec();
644
+ let (stderr_visible, stderr_truncated) =
645
+ capture::clip_capture(&stderr_raw, self.config.stderr_limit_bytes);
646
+ let stderr_bytes = stderr_visible.to_vec();
647
+ Ok(Snapshot::new(
648
+ return_bytes,
649
+ stdout_bytes,
650
+ stdout_truncated,
651
+ stderr_bytes,
652
+ stderr_truncated,
653
+ wall_time,
654
+ memory_peak,
655
+ ))
688
656
  }
657
+ }
689
658
 
690
- /// Allocate a `len`-byte buffer in guest linear memory via
691
- /// `__kobako_alloc`, copy `envelope` into it, and return `(ptr, len)`
692
- /// as `i32` values matching the `__kobako_run(env_ptr, env_len)` ABI.
693
- /// Raises `Kobako::TrapError` when the allocation hook is missing or
694
- /// itself traps, and `Kobako::SandboxError` when the hook runs but
695
- /// cannot reserve the buffer (`__kobako_alloc` returns 0,
696
- /// docs/behavior.md E-31) — an intact runtime, not an engine fault.
697
- fn write_envelope(&self, ruby: &Ruby, envelope: RString) -> Result<(i32, i32), MagnusError> {
698
- let bytes = rstring_to_vec(envelope);
699
- let len_i32 =
700
- guest_mem::checked_payload_len(bytes.len()).map_err(|msg| trap_err(ruby, msg))?;
701
-
702
- let mut store_ref = self.store.borrow_mut();
703
- let alloc: TypedFunc<u32, u32> = self
704
- .inner
705
- .get_typed_func(store_ref.as_context_mut(), "__kobako_alloc")
706
- .map_err(|_| trap_err(ruby, SANDBOX_RUNTIME_MISSING_HOOKS))?;
707
- let ptr = alloc
708
- .call(store_ref.as_context_mut(), bytes.len() as u32)
709
- .map_err(|e| trap_err(ruby, format!("failed to allocate input buffer: {}", e)))?;
710
- if ptr == 0 {
711
- return Err(sandbox_err(
712
- ruby,
713
- "could not allocate input buffer (out of memory)",
714
- ));
715
- }
659
+ /// Drop the memory cap as soon as the guest call returns so that
660
+ /// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
661
+ /// which can grow guest memory transiently) is not attributed to
662
+ /// the user script. Also closes the
663
+ /// `wall_time` bracket opened by `Runtime::prime_caps`. Paired
664
+ /// with `Runtime::prime_caps`.
665
+ fn disarm_caps(store: &mut WtStore<Invocation>) {
666
+ store.data_mut().stop_wall_clock();
667
+ store.data_mut().disarm_memory_cap();
668
+ }
716
669
 
717
- let memory: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
718
- Some(Extern::Memory(m)) => m,
719
- _ => return Err(trap_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO)),
720
- };
721
- let data = memory.data_mut(store_ref.as_context_mut());
722
- let range = guest_mem::guest_buffer_range(ptr as usize, bytes.len(), data.len())
723
- .map_err(|msg| trap_err(ruby, msg))?;
724
- data[range].copy_from_slice(&bytes);
670
+ /// Return the resolved `memory` export handle, or raise
671
+ /// `Kobako::TrapError` when the loaded module exports no linear
672
+ /// memory the "not a Kobako-shaped runtime" failure mode
673
+ /// (`SANDBOX_RUNTIME_NOT_KOBAKO`).
674
+ fn require_memory(ruby: &Ruby, exports: &Exports) -> Result<Memory, MagnusError> {
675
+ exports
676
+ .memory
677
+ .ok_or_else(|| trap_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO))
678
+ }
725
679
 
726
- Ok((ptr as i32, len_i32))
680
+ /// Allocate a `len`-byte buffer in guest linear memory via
681
+ /// `__kobako_alloc`, copy `envelope` into it, and return `(ptr, len)`
682
+ /// as `i32` values matching the `__kobako_run(env_ptr, env_len)` ABI.
683
+ /// Raises `Kobako::TrapError` when the allocation hook is missing or
684
+ /// itself traps, and `Kobako::SandboxError` when the hook runs but
685
+ /// cannot reserve the buffer (`__kobako_alloc` returns 0) — an
686
+ /// intact runtime, not an engine fault.
687
+ fn write_envelope(
688
+ ruby: &Ruby,
689
+ store: &mut WtStore<Invocation>,
690
+ exports: &Exports,
691
+ envelope: RString,
692
+ ) -> Result<(i32, i32), MagnusError> {
693
+ let bytes = rstring_to_vec(envelope);
694
+ let len_i32 = guest_mem::checked_payload_len(bytes.len()).map_err(|msg| trap_err(ruby, msg))?;
695
+
696
+ let alloc = require_export(ruby, exports.alloc.as_ref())?;
697
+ let memory = require_memory(ruby, exports)?;
698
+
699
+ let ptr = alloc
700
+ .call(store.as_context_mut(), bytes.len() as u32)
701
+ .map_err(|e| trap_err(ruby, format!("failed to allocate input buffer: {}", e)))?;
702
+ if ptr == 0 {
703
+ return Err(sandbox_err(
704
+ ruby,
705
+ "could not allocate input buffer (out of memory)",
706
+ ));
727
707
  }
708
+ let data = memory.data_mut(store.as_context_mut());
709
+ let range = guest_mem::guest_buffer_range(ptr as usize, bytes.len(), data.len())
710
+ .map_err(|msg| trap_err(ruby, msg))?;
711
+ data[range].copy_from_slice(&bytes);
728
712
 
729
- /// Rebuild the WASI context with fresh stdin (carrying every frame in
730
- /// `frames`, each prefixed by its 4-byte big-endian u32 length —
731
- /// docs/wire-codec.md § Invocation channels) plus fresh stdout / stderr
732
- /// pipes. Called at the top of every guest invocation: `#eval` passes
733
- /// three frames (preamble, source, snippets), `#run` passes two
734
- /// (preamble, snippets — the invocation envelope arrives via linear
735
- /// memory instead). Each output pipe is sized at `cap + 1` so
736
- /// `capture::clip_capture` can distinguish "wrote exactly cap
737
- /// bytes" from "exceeded cap"; uncapped channels fall back
738
- /// to `usize::MAX` and rely on `memory_limit` (docs/behavior.md E-20)
739
- /// for the real ceiling. Raises `Kobako::TrapError` when any frame
740
- /// exceeds the 16 MiB cap that keeps its `u32` length prefix from
741
- /// wrapping.
742
- fn refresh_wasi(&self, frames: &[Vec<u8>]) -> Result<(), MagnusError> {
743
- let ruby = Ruby::get().expect("Ruby thread");
744
- // Every frame carries the same 16 MiB cap as the `#run` envelope
745
- // (`write_envelope`): the length prefix is a `u32`, so a frame past
746
- // the cap would silently wrap and corrupt the stdin frame stream.
747
- for frame in frames {
748
- guest_mem::checked_payload_len(frame.len()).map_err(|msg| trap_err(&ruby, msg))?;
749
- }
713
+ Ok((ptr as i32, len_i32))
714
+ }
750
715
 
751
- let total: usize = frames.iter().map(|f| 4 + f.len()).sum();
752
- let mut stdin_content: Vec<u8> = Vec::with_capacity(total);
753
- for frame in frames {
754
- stdin_content.extend_from_slice(&(frame.len() as u32).to_be_bytes());
755
- stdin_content.extend_from_slice(frame);
756
- }
716
+ /// Build the per-invocation WASI context with stdin carrying every frame
717
+ /// in `frames` (each prefixed by its 4-byte big-endian u32 length —
718
+ /// docs/wire-codec.md § Invocation channels) plus fresh stdout / stderr
719
+ /// pipes, and install it on the invocation's Store. `#eval` passes three
720
+ /// frames (preamble, source, snippets), `#run` passes two (preamble,
721
+ /// snippets — the invocation envelope arrives via linear memory
722
+ /// instead). Each output pipe is sized at `cap + 1` so
723
+ /// `capture::clip_capture` can distinguish "wrote exactly cap bytes"
724
+ /// from "exceeded cap"; uncapped channels fall back to `usize::MAX` and
725
+ /// rely on `memory_limit` for the real ceiling.
726
+ /// Raises `Kobako::TrapError` when any frame exceeds the 16 MiB cap that
727
+ /// keeps its `u32` length prefix from wrapping.
728
+ fn install_wasi_frames(
729
+ store: &mut WtStore<Invocation>,
730
+ config: &Config,
731
+ frames: &[Vec<u8>],
732
+ ) -> Result<(), MagnusError> {
733
+ let ruby = Ruby::get().expect("Ruby thread");
734
+ // Every frame carries the same 16 MiB cap as the `#run` envelope
735
+ // (`write_envelope`): the length prefix is a `u32`, so a frame past
736
+ // the cap would silently wrap and corrupt the stdin frame stream.
737
+ for frame in frames {
738
+ guest_mem::checked_payload_len(frame.len()).map_err(|msg| trap_err(&ruby, msg))?;
739
+ }
757
740
 
758
- let stdin_pipe = MemoryInputPipe::new(stdin_content);
759
- let stdout_pipe =
760
- MemoryOutputPipe::new(capture::pipe_capacity(self.config.stdout_limit_bytes));
761
- let stderr_pipe =
762
- MemoryOutputPipe::new(capture::pipe_capacity(self.config.stderr_limit_bytes));
763
-
764
- let mut builder = WasiCtxBuilder::new();
765
- builder.stdin(stdin_pipe);
766
- builder.stdout(stdout_pipe.clone());
767
- builder.stderr(stderr_pipe.clone());
768
- // Deny the preview1 ambient-authority imports the guest never legitimately
769
- // reaches but the WASI layer would otherwise grant (see `ambient`).
770
- builder.wall_clock(ambient::FrozenWallClock);
771
- builder.monotonic_clock(ambient::FrozenMonotonicClock);
772
- builder.secure_random(ambient::deterministic_rng());
773
- let wasi = builder.build_p1();
774
-
775
- self.store
776
- .borrow_mut()
777
- .data_mut()
778
- .install_wasi(wasi, stdout_pipe, stderr_pipe);
779
- Ok(())
741
+ let total: usize = frames.iter().map(|f| 4 + f.len()).sum();
742
+ let mut stdin_content: Vec<u8> = Vec::with_capacity(total);
743
+ for frame in frames {
744
+ stdin_content.extend_from_slice(&(frame.len() as u32).to_be_bytes());
745
+ stdin_content.extend_from_slice(frame);
780
746
  }
781
747
 
782
- /// Invoke `__kobako_take_outcome`, decode the packed `(ptr<<32)|len`
783
- /// u64, and copy the OUTCOME_BUFFER slice out of guest memory. Raises
784
- /// `Kobako::TrapError` when the export is missing, `len` exceeds the
785
- /// 16 MiB single-dispatch cap, the `ptr`/`len` arithmetic overflows,
786
- /// the slice falls outside live memory, or the `memory` export itself
787
- /// is absent.
788
- fn fetch_outcome_bytes(&self, ruby: &Ruby) -> Result<Vec<u8>, MagnusError> {
789
- let take = require_export(ruby, self.exports.take_outcome.as_ref())?;
790
-
791
- let mut store_ref = self.store.borrow_mut();
792
- let packed = take
793
- .call(store_ref.as_context_mut(), ())
794
- .map_err(|e| trap_err(ruby, format!("failed to read the Sandbox result: {}", e)))?;
795
- let (ptr, len) = guest_mem::unpack_outcome_packed(packed);
796
- if len > guest_mem::MAX_DISPATCH_PAYLOAD {
797
- return Err(trap_err(ruby, "result payload exceeds the 16 MiB limit"));
798
- }
748
+ let stdin_pipe = MemoryInputPipe::new(stdin_content);
749
+ let stdout_pipe = MemoryOutputPipe::new(capture::pipe_capacity(config.stdout_limit_bytes));
750
+ let stderr_pipe = MemoryOutputPipe::new(capture::pipe_capacity(config.stderr_limit_bytes));
751
+
752
+ let mut builder = WasiCtxBuilder::new();
753
+ builder.stdin(stdin_pipe);
754
+ builder.stdout(stdout_pipe.clone());
755
+ builder.stderr(stderr_pipe.clone());
756
+ // Deny the preview1 ambient-authority imports the guest never legitimately
757
+ // reaches but the WASI layer would otherwise grant (see `ambient`).
758
+ builder.wall_clock(ambient::FrozenWallClock);
759
+ builder.monotonic_clock(ambient::FrozenMonotonicClock);
760
+ builder.secure_random(ambient::deterministic_rng());
761
+ let wasi = builder.build_p1();
762
+
763
+ store
764
+ .data_mut()
765
+ .install_wasi(wasi, stdout_pipe, stderr_pipe);
766
+ Ok(())
767
+ }
799
768
 
800
- let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
801
- Some(Extern::Memory(m)) => m,
802
- _ => return Err(trap_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO)),
803
- };
804
- let data = mem.data(store_ref.as_context_mut());
805
- let range = guest_mem::guest_buffer_range(ptr, len, data.len()).map_err(|msg| {
806
- trap_err(
807
- ruby,
808
- format!("the Sandbox result is out of bounds: {}", msg),
809
- )
810
- })?;
811
- Ok(data[range].to_vec())
769
+ /// Invoke `__kobako_take_outcome`, decode the packed `(ptr<<32)|len`
770
+ /// u64, and copy the OUTCOME_BUFFER slice out of guest memory. Raises
771
+ /// `Kobako::TrapError` when the export is missing, `len` exceeds the
772
+ /// 16 MiB single-dispatch cap, the `ptr`/`len` arithmetic overflows,
773
+ /// the slice falls outside live memory, or the `memory` export itself
774
+ /// is absent.
775
+ fn fetch_outcome_bytes(
776
+ ruby: &Ruby,
777
+ store: &mut WtStore<Invocation>,
778
+ exports: &Exports,
779
+ ) -> Result<Vec<u8>, MagnusError> {
780
+ let take = require_export(ruby, exports.take_outcome.as_ref())?;
781
+ let mem = require_memory(ruby, exports)?;
782
+
783
+ let packed = take
784
+ .call(store.as_context_mut(), ())
785
+ .map_err(|e| trap_err(ruby, format!("failed to read the Sandbox result: {}", e)))?;
786
+ let (ptr, len) = guest_mem::unpack_outcome_packed(packed);
787
+ if len > guest_mem::MAX_DISPATCH_PAYLOAD {
788
+ return Err(trap_err(ruby, "result payload exceeds the 16 MiB limit"));
812
789
  }
790
+
791
+ let data = mem.data(store.as_context_mut());
792
+ let range = guest_mem::guest_buffer_range(ptr, len, data.len()).map_err(|msg| {
793
+ trap_err(
794
+ ruby,
795
+ format!("the Sandbox result is out of bounds: {}", msg),
796
+ )
797
+ })?;
798
+ Ok(data[range].to_vec())
813
799
  }
814
800
 
815
801
  /// User-facing message for the "Sandbox runtime is missing one of the
@@ -829,7 +815,7 @@ const SANDBOX_RUNTIME_MISSING_HOOKS: &str = "Sandbox runtime is missing required
829
815
  const SANDBOX_RUNTIME_NOT_KOBAKO: &str =
830
816
  "the loaded Wasm module is not a Kobako-compatible runtime";
831
817
 
832
- /// Return the cached `TypedFunc` for an ABI export, or raise
818
+ /// Return the resolved `TypedFunc` for an ABI export, or raise
833
819
  /// `Kobako::TrapError` when the option is `None`. Both run-path
834
820
  /// methods (`#eval`, `#run`) plus the `build_snapshot` readout that
835
821
  /// drains `OUTCOME_BUFFER` share the same "missing export → Ruby