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