kobako 0.12.1 → 0.12.2

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +11 -0
  4. data/Cargo.lock +15 -2
  5. data/Cargo.toml +6 -2
  6. data/README.md +1 -1
  7. data/crates/kobako-runtime/CHANGELOG.md +8 -0
  8. data/crates/kobako-runtime/Cargo.toml +23 -0
  9. data/crates/kobako-runtime/README.md +34 -0
  10. data/crates/kobako-runtime/src/dispatch.rs +22 -0
  11. data/crates/kobako-runtime/src/error.rs +64 -0
  12. data/crates/kobako-runtime/src/lib.rs +16 -0
  13. data/crates/kobako-runtime/src/runtime.rs +50 -0
  14. data/crates/kobako-runtime/src/snapshot.rs +46 -0
  15. data/crates/kobako-runtime/src/yielder.rs +22 -0
  16. data/crates/kobako-wasmtime/CHANGELOG.md +8 -0
  17. data/crates/kobako-wasmtime/Cargo.toml +62 -0
  18. data/crates/kobako-wasmtime/README.md +32 -0
  19. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/ambient.rs +3 -3
  20. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/cache.rs +30 -41
  21. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/capture.rs +2 -2
  22. data/crates/kobako-wasmtime/src/config.rs +25 -0
  23. data/crates/kobako-wasmtime/src/dispatch.rs +110 -0
  24. data/crates/kobako-wasmtime/src/driver.rs +285 -0
  25. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/exports.rs +5 -6
  26. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/frames.rs +70 -82
  27. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/guest_mem.rs +38 -15
  28. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/instance_pre.rs +13 -21
  29. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/invocation.rs +54 -49
  30. data/crates/kobako-wasmtime/src/lib.rs +47 -0
  31. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/trap.rs +29 -35
  32. data/data/kobako.wasm +0 -0
  33. data/ext/kobako/Cargo.toml +9 -32
  34. data/ext/kobako/src/runtime/bridge.rs +150 -0
  35. data/ext/kobako/src/runtime/errors.rs +45 -13
  36. data/ext/kobako/src/runtime.rs +156 -406
  37. data/ext/kobako/src/snapshot.rs +27 -62
  38. data/lib/kobako/catalog/handles.rb +3 -3
  39. data/lib/kobako/catalog/namespaces.rb +4 -0
  40. data/lib/kobako/catalog/snippets.rb +4 -0
  41. data/lib/kobako/codec/encoder.rb +5 -1
  42. data/lib/kobako/codec/factory.rb +41 -13
  43. data/lib/kobako/codec/handle_walk.rb +4 -0
  44. data/lib/kobako/errors.rb +18 -16
  45. data/lib/kobako/sandbox.rb +20 -18
  46. data/lib/kobako/sandbox_options.rb +25 -9
  47. data/lib/kobako/snapshot.rb +7 -13
  48. data/lib/kobako/transport/dispatcher.rb +2 -2
  49. data/lib/kobako/transport/response.rb +14 -14
  50. data/lib/kobako/transport/run.rb +2 -6
  51. data/lib/kobako/transport/yield.rb +1 -1
  52. data/lib/kobako/transport/yielder.rb +2 -2
  53. data/lib/kobako/version.rb +1 -1
  54. data/release-please-config.json +48 -3
  55. data/sig/kobako/codec/factory.rbs +3 -0
  56. data/sig/kobako/errors.rbs +7 -14
  57. data/sig/kobako/runtime.rbs +8 -3
  58. data/sig/kobako/sandbox.rbs +2 -2
  59. data/sig/kobako/sandbox_options.rbs +4 -2
  60. data/sig/kobako/snapshot.rbs +0 -3
  61. data/sig/kobako/transport/dispatcher.rbs +1 -1
  62. data/sig/kobako/transport/run.rbs +2 -2
  63. data/sig/kobako/transport/yielder.rbs +2 -2
  64. data/sig/kobako/transport.rbs +8 -0
  65. metadata +27 -12
  66. data/ext/kobako/src/runtime/config.rs +0 -25
  67. data/ext/kobako/src/runtime/dispatch.rs +0 -211
@@ -1,82 +1,45 @@
1
- // Host-side wasmtime runtime wrapper.
2
- //
3
- // The only Ruby-visible class is
4
- //
5
- // Kobako::Runtime — wraps a pre-linked InstancePre + per-Runtime caps
6
- //
7
- // constructed via `Kobako::Runtime.from_path(path, timeout, memory_limit,
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").
16
- //
17
- // Module layout (per CLAUDE.md principle #2 one responsibility per file):
18
- //
19
- // * `cache` process-wide Engine + per-path Module cache and the
20
- // process-singleton epoch ticker thread.
21
- // * `config` — per-Runtime caps (timeout / stdout / stderr limits).
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`).
29
- // * `dispatch` — `__kobako_dispatch` host-import dispatch helpers.
30
- // * `guest_mem` — Caller-based guest linear-memory alloc / write / read.
31
- // * `capture` — stdout / stderr pipe sizing + clip helpers.
32
- // * `trap` — wasmtime-error → `Kobako::*` trap classification.
33
- //
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` /
37
- // `timeout_err` / `memory_limit_err` / `setup_err` constructors shared by
38
- // every submodule, and the Ruby init() that registers the class.
39
-
40
- mod ambient;
41
- mod cache;
42
- mod capture;
43
- mod config;
44
- mod dispatch;
1
+ //! Host-side magnus shell over the extracted wasmtime driver.
2
+ //!
3
+ //! The only Ruby-visible class is
4
+ //!
5
+ //! Kobako::Runtime — wraps a `kobako_wasmtime::Driver` + the Ruby seams
6
+ //!
7
+ //! constructed via `Kobako::Runtime.from_path(path, timeout, memory_limit,
8
+ //! stdout_limit, stderr_limit)`. Every invocation (`#eval` / `#run`)
9
+ //! instantiates a fresh instance and discards the whole Store afterwards
10
+ //! the per-invocation instance discipline. The run mechanics —
11
+ //! engine/module caches, caps, trap classification live in the
12
+ //! `kobako-wasmtime` crate behind the `kobako_runtime` contract; no wasm
13
+ //! engine type reaches this crate or the Host App.
14
+ //!
15
+ //! Module layout — one responsibility per file:
16
+ //!
17
+ //! * `bridge` the magnus dispatch bridge: `RubyDispatchHandler` plus the
18
+ //! frame-scoped `GuestYielder` Ruby class.
19
+ //! * `errors` the single boundary mapping the neutral `Trap` /
20
+ //! `SetupError` channels onto the `Kobako::*` classes.
21
+ //!
22
+ //! This file owns the `Kobako::Runtime` magnus class itself — the Ruby
23
+ //! init() that registers the class, the byte↔`RString` shuttling, the
24
+ //! dispatch-Proc GC root, and the per-invocation usage readout.
25
+
26
+ mod bridge;
45
27
  mod errors;
46
- mod exports;
47
- mod frames;
48
- mod guest_mem;
49
- mod instance_pre;
50
- mod invocation;
51
- mod trap;
52
28
 
53
29
  use magnus::{function, method, prelude::*, Error as MagnusError, RModule, RString, Ruby};
54
30
 
55
31
  use std::cell::Cell;
56
32
  use std::path::Path;
57
- use std::time::{Duration, Instant};
33
+ use std::sync::Arc;
34
+ use std::time::Duration;
58
35
 
59
36
  use magnus::{gc, typed_data::DataTypeFunctions, value::Opaque, RArray, TypedData, Value};
60
37
 
61
38
  use crate::snapshot::Snapshot;
62
- use wasmtime::{
63
- AsContextMut, InstancePre as WtInstancePre, ResourceLimiter, Store as WtStore, TypedFunc,
64
- };
65
-
66
- use self::cache::shared_engine;
67
- use self::config::Config;
68
- use self::exports::Exports;
69
- use self::invocation::Invocation;
70
-
71
- /// The wire ABI version this host implements (docs/wire-codec.md § ABI
72
- /// Version). A Guest Binary is accepted only when its
73
- /// `__kobako_abi_version` export reports the same value; a mismatch
74
- /// is a deterministic artifact fault. The guest-side mirror is
75
- /// `kobako_core::abi::ABI_VERSION`. Version 2
76
- /// carries the per-invocation instance discipline: the host
77
- /// drives every invocation on a fresh instance, so the guest may leave
78
- /// its VM state dirty at exit.
79
- const ABI_VERSION: u32 = 2;
39
+ use kobako_runtime::dispatch::DispatchHandler;
40
+ use kobako_runtime::runtime::{Entry, Frames, Runtime as ContractRuntime};
41
+ use kobako_runtime::snapshot::{Completion, Snapshot as RuntimeSnapshot, Usage};
42
+ use kobako_wasmtime::{Config, Driver};
80
43
 
81
44
  /// Copy the bytes of `s` into a fresh `Vec<u8>`. Single safe entry to
82
45
  /// what would otherwise be an inline `unsafe { rstring.as_slice() }
@@ -84,7 +47,7 @@ const ABI_VERSION: u32 = 2;
84
47
  /// does not outlive this call, so no Ruby allocation can move the
85
48
  /// underlying RString between the borrow and the copy — the safety
86
49
  /// invariant the inline form relied on is established once here.
87
- pub(crate) fn rstring_to_vec(s: RString) -> Vec<u8> {
50
+ fn rstring_to_vec(s: RString) -> Vec<u8> {
88
51
  // SAFETY: see item doc.
89
52
  unsafe { s.as_slice() }.to_vec()
90
53
  }
@@ -104,47 +67,37 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
104
67
  let runtime = kobako.define_class("Runtime", ruby.class_object())?;
105
68
  runtime.define_singleton_method("from_path", function!(Runtime::from_path, 5))?;
106
69
  runtime.define_method("on_dispatch=", method!(Runtime::set_on_dispatch, 1))?;
107
- runtime.define_method(
108
- "yield_to_active_invocation",
109
- method!(Runtime::yield_to_active_invocation, 1),
110
- )?;
111
70
  runtime.define_method("eval", method!(Runtime::eval, 3))?;
112
71
  runtime.define_method("run", method!(Runtime::run, 3))?;
113
72
  runtime.define_method("usage", method!(Runtime::usage, 0))?;
73
+ // The guest re-enters for a block yield through a frame-scoped
74
+ // `Kobako::Runtime::GuestYielder` the dispatcher hands the Proc, not a
75
+ // method on Runtime.
76
+ bridge::register(runtime)?;
114
77
 
115
78
  Ok(())
116
79
  }
117
80
 
118
81
  #[derive(TypedData)]
119
82
  #[magnus(class = "Kobako::Runtime", free_immediately, size, mark)]
120
- pub(crate) struct Runtime {
121
- // Pre-linked instantiation template (import wiring + type checks
122
- // done once in `instance_pre::cached_instance_pre`). Every
123
- // invocation instantiates a fresh instance from it and discards the
124
- // whole Store afterwards — the per-invocation instance discipline.
125
- instance_pre: WtInstancePre<Invocation>,
126
- // Per-invocation linear-memory cap,
127
- // threaded into each fresh `Invocation`; lives apart from `Config`
128
- // because the wasmtime `ResourceLimiter` callback consumes it from
129
- // inside the wasm engine.
130
- memory_limit: Option<usize>,
131
- // Wall-clock + per-channel capture caps forwarded from the Sandbox;
132
- // see `Config`.
133
- config: Config,
83
+ struct Runtime {
84
+ // The magnus-free wasmtime driver that runs every invocation; the
85
+ // shell only shuttles Ruby values across its boundary.
86
+ driver: Driver,
134
87
  // The host-side dispatch Proc, held here only
135
88
  // to give `DataTypeFunctions::mark` a read path so it can pin the
136
- // Proc across GC. The copy the `__kobako_dispatch` import actually
137
- // calls is bound onto each per-invocation `Invocation` by
138
- // `Runtime::new_store`. Both hold the same `Copy` handle to the one
139
- // pinned Proc. `Cell` is sound under the GVL (see the `unsafe impl
140
- // Sync` below).
89
+ // Proc across GC. For each invocation `build_handler` wraps a copy of
90
+ // this handle in a `RubyDispatchHandler`, and the driver's `invoke`
91
+ // binds that `Arc<dyn DispatchHandler>` onto the per-invocation
92
+ // `Invocation`, where the `__kobako_dispatch` import calls it both
93
+ // reference the one Proc this `Opaque` pins. `Cell` is sound under the
94
+ // GVL (see the `unsafe impl Sync` below).
141
95
  on_dispatch: Cell<Option<Opaque<Value>>>,
142
- // Usage of the most recent invocation
143
- // `(wall_time_seconds, memory_peak_bytes)` captured by
144
- // `build_snapshot` before the per-invocation Store is discarded so
145
- // `#usage` reads survive the teardown. `(0.0, 0)` before the first
96
+ // Usage of the most recent invocation, kept apart from the returned
97
+ // `Snapshot` so `#usage` reads survive the per-invocation Store
98
+ // teardown and the trap path's raise. Zeroed before the first
146
99
  // invocation.
147
- last_usage: Cell<(f64, usize)>,
100
+ last_usage: Cell<Usage>,
148
101
  }
149
102
 
150
103
  impl DataTypeFunctions for Runtime {
@@ -185,7 +138,7 @@ impl Runtime {
185
138
  /// disables). All four are validated by the caller
186
139
  /// (`Kobako::Sandbox`); this method only refuses non-finite or
187
140
  /// non-positive timeouts as a defence in depth.
188
- pub(crate) fn from_path(
141
+ fn from_path(
189
142
  path: String,
190
143
  timeout_seconds: Option<f64>,
191
144
  memory_limit: Option<usize>,
@@ -209,112 +162,38 @@ impl Runtime {
209
162
  }
210
163
  };
211
164
 
212
- let runtime = Self {
213
- instance_pre: instance_pre::cached_instance_pre(Path::new(&path))?,
165
+ let driver = Driver::new(
166
+ Path::new(&path),
214
167
  memory_limit,
215
- config: Config {
168
+ Config {
216
169
  timeout,
217
170
  stdout_limit_bytes,
218
171
  stderr_limit_bytes,
219
172
  },
173
+ )
174
+ .map_err(|e| errors::setup_to_magnus(&ruby, e))?;
175
+ Ok(Self {
176
+ driver,
220
177
  on_dispatch: Cell::new(None),
221
- last_usage: Cell::new((0.0, 0)),
222
- };
223
- runtime.probe_abi_version(&ruby)?;
224
- Ok(runtime)
225
- }
226
-
227
- /// Instantiate a throwaway probe instance at construction and require
228
- /// the guest's `__kobako_abi_version` export to equal `ABI_VERSION`
229
- /// An absent export or a non-equal value is
230
- /// a deterministic artifact fault raised as
231
- /// `Kobako::SetupError`. The probe Store drops here; invocation
232
- /// instances are created per `#eval` / `#run`. The frameless WASI
233
- /// context keeps a third-party guest whose start section touches
234
- /// WASI on the `SetupError` path instead of panicking in
235
- /// `Invocation::wasi_mut`.
236
- fn probe_abi_version(&self, ruby: &Ruby) -> Result<(), MagnusError> {
237
- let mut store = self.new_store()?;
238
- frames::install_wasi_frames(&mut store, &self.config, &[])?;
239
- let instance = self
240
- .instance_pre
241
- .instantiate(store.as_context_mut())
242
- .map_err(|e| trap::instantiate_err(ruby, e))?;
243
- let probe = instance
244
- .get_typed_func::<(), u32>(store.as_context_mut(), "__kobako_abi_version")
245
- .map_err(|_| {
246
- errors::setup_err(
247
- ruby,
248
- format!(
249
- "the Guest Binary does not export __kobako_abi_version; \
250
- rebuild it against ABI version {ABI_VERSION}"
251
- ),
252
- )
253
- })?;
254
- let reported = probe.call(store.as_context_mut(), ()).map_err(|e| {
255
- errors::setup_err(
256
- ruby,
257
- format!("failed to read the Guest Binary's ABI version: {e}"),
258
- )
259
- })?;
260
- if reported != ABI_VERSION {
261
- return Err(errors::setup_err(
262
- ruby,
263
- format!(
264
- "the Guest Binary reports ABI version {reported}, but this host \
265
- implements ABI version {ABI_VERSION}; rebuild the Guest Binary \
266
- against the host's version"
267
- ),
268
- ));
269
- }
270
- Ok(())
178
+ last_usage: Cell::new(Usage {
179
+ wall_time: 0.0,
180
+ memory_peak: 0,
181
+ }),
182
+ })
271
183
  }
272
184
 
273
185
  /// Register the Ruby-side dispatch `Proc`.
274
186
  /// Bound to Ruby as `Kobako::Runtime#on_dispatch=`. The handle is
275
- /// pinned by `DataTypeFunctions::mark` and copied onto every
276
- /// per-invocation `Invocation` by `Runtime::new_store`, where the
277
- /// `__kobako_dispatch` import reads it through `Caller<Invocation>`.
278
- pub(crate) fn set_on_dispatch(&self, proc_value: Value) -> Result<(), MagnusError> {
187
+ /// pinned by `DataTypeFunctions::mark`; for each invocation
188
+ /// `build_handler` wraps a copy in a `RubyDispatchHandler` and the
189
+ /// driver's `invoke` binds it onto the per-invocation `Invocation`,
190
+ /// where the `__kobako_dispatch` import reads it through
191
+ /// `Caller<Invocation>`.
192
+ fn set_on_dispatch(&self, proc_value: Value) -> Result<(), MagnusError> {
279
193
  self.on_dispatch.set(Some(Opaque::from(proc_value)));
280
194
  Ok(())
281
195
  }
282
196
 
283
- /// Synchronously re-enter the guest's `__kobako_yield_to_block`
284
- /// export with `args_bytes` as the yield-arguments payload, and
285
- /// return the YieldResponse bytes the guest produced.
286
- ///
287
- /// Bound to Ruby as `Kobako::Runtime#yield_to_active_invocation`.
288
- /// Recovers the dispatcher's `&mut Caller` from the per-thread
289
- /// Invocation slot (SPEC.md Single-Invocation Slot) — the host is
290
- /// already inside a `__kobako_dispatch` callback, so the Caller
291
- /// parked on the Rust stack is the same one the Sandbox-level
292
- /// `#eval` / `#run` is driving. Invoked from the host-side yield
293
- /// proxy that the dispatcher hands to Service methods;
294
- /// raises `Kobako::TrapError` when called outside an active dispatch
295
- /// frame, or when any of the underlying allocation / write / call /
296
- /// read steps fails.
297
- pub(crate) fn yield_to_active_invocation(
298
- &self,
299
- args_bytes: RString,
300
- ) -> Result<RString, MagnusError> {
301
- let ruby = Ruby::get().expect("Ruby thread");
302
- let _ = self; // The Caller carries its own Store; `self` is only
303
- // a marker that the method belongs to a Runtime.
304
-
305
- let bytes = rstring_to_vec(args_bytes);
306
- let Some(caller) = dispatch::current_caller() else {
307
- return Err(errors::trap_err(
308
- &ruby,
309
- "yield_to_active_invocation called outside an active Sandbox dispatch frame",
310
- ));
311
- };
312
-
313
- let resp_bytes =
314
- guest_mem::drive_yield(caller, &bytes).map_err(|msg| errors::trap_err(&ruby, msg))?;
315
- Ok(ruby.str_from_slice(&resp_bytes))
316
- }
317
-
318
197
  // -----------------------------------------------------------------
319
198
  // Run-path methods. Each method is best-effort — it raises a Ruby
320
199
  // `Kobako::TrapError` when the corresponding export is missing or
@@ -322,75 +201,100 @@ impl Runtime {
322
201
  // taxonomy.
323
202
  // -----------------------------------------------------------------
324
203
 
325
- /// Execute one guest invocation (`__kobako_eval` one-shot source)
326
- /// and return a `Snapshot` bundling every per-invocation observable.
327
- ///
328
- /// Builds a fresh Store + instance whose WASI context carries
329
- /// the three-frame stdin protocol (`preamble`, `source`, `snippets`
330
- /// docs/wire-codec.md § Invocation channels), then invokes
331
- /// `__kobako_eval`. Per-invocation caps are
332
- /// primed here: the wall-clock deadline is stamped into `Invocation`
333
- /// and the epoch deadline is set to fire at the next ticker tick;
334
- /// the memory-cap limiter is already wired.
335
- ///
336
- /// On a wasmtime trap the configured-cap path raises
337
- /// `Kobako::TimeoutError` / `Kobako::MemoryLimitError`; everything
338
- /// else raises `Kobako::TrapError`. On success the Snapshot carries
339
- /// the OUTCOME_BUFFER bytes, the per-channel stdout / stderr captures
340
- /// with their truncation flags, and the usage figures.
341
- pub(crate) fn eval(
204
+ /// One-shot mruby source execution (`#eval`). The Ruby-facing entry:
205
+ /// builds the dispatch handler from the registered Proc, hands the
206
+ /// three stdin frames (`preamble`, `source`, `snippets`) and the source
207
+ /// to the driver, and settles the returned `Snapshot` through
208
+ /// `finish_invocation` or maps a could-not-start `Error` onto its
209
+ /// `Kobako::*` exception. The run mechanics — frames, caps, trap
210
+ /// classification live in `kobako_wasmtime::Driver`.
211
+ fn eval(
342
212
  &self,
343
213
  preamble: RString,
344
214
  source: RString,
345
215
  snippets: RString,
346
216
  ) -> Result<Snapshot, MagnusError> {
347
217
  let ruby = Ruby::get().expect("Ruby thread");
348
- let mut store = self.new_store()?;
349
- frames::install_wasi_frames(
350
- &mut store,
351
- &self.config,
352
- &[
353
- rstring_to_vec(preamble),
354
- rstring_to_vec(source),
355
- rstring_to_vec(snippets),
356
- ],
357
- )?;
358
- let exports = self.instantiate(&ruby, &mut store)?;
359
- let eval = frames::require_export(&ruby, exports.eval.as_ref())?;
360
- self.call_with_caps(&mut store, &exports, eval, ())
361
- .map_err(|e| trap::call_err(&ruby, e))?;
362
- self.build_snapshot(&ruby, &mut store, &exports)
218
+ let handler = self.build_handler();
219
+ let preamble = rstring_to_vec(preamble);
220
+ let source = rstring_to_vec(source);
221
+ let snippets = rstring_to_vec(snippets);
222
+ let snapshot = self
223
+ .driver
224
+ .invoke(
225
+ Entry::Eval { source: &source },
226
+ Frames {
227
+ preamble: &preamble,
228
+ snippets: &snippets,
229
+ },
230
+ handler,
231
+ )
232
+ .map_err(|e| errors::to_magnus(&ruby, e))?;
233
+ self.finish_invocation(&ruby, snapshot)
363
234
  }
364
235
 
365
236
  /// Execute one entrypoint dispatch (`__kobako_run`) and return a
366
237
  /// `Snapshot` bundling every per-invocation observable.
367
238
  ///
368
- /// Builds a fresh Store + instance whose WASI context carries
369
- /// the two-frame stdin protocol (preamble + snippets; no user source
370
- /// frame docs/wire-codec.md § Invocation channels), copies
371
- /// `envelope` bytes into guest linear memory via `__kobako_alloc`,
372
- /// and calls `__kobako_run(env_ptr, env_len)`. Per-invocation cap
373
- /// semantics match `Runtime::eval`. Raises `Kobako::TrapError`
374
- /// ("alloc returned 0") when guest allocation fails.
375
- pub(crate) fn run(
239
+ /// The two-frame stdin protocol (preamble + snippets; no user source
240
+ /// frame docs/wire-codec.md § Invocation channels) plus the
241
+ /// `envelope` copied into guest linear memory; cap semantics match
242
+ /// `#eval`. Raises `Kobako::TrapError` / `Kobako::SandboxError` per the
243
+ /// engine-vs-host-fault split inside the driver.
244
+ fn run(
376
245
  &self,
377
246
  preamble: RString,
378
247
  snippets: RString,
379
248
  envelope: RString,
380
249
  ) -> Result<Snapshot, MagnusError> {
381
250
  let ruby = Ruby::get().expect("Ruby thread");
382
- let mut store = self.new_store()?;
383
- frames::install_wasi_frames(
384
- &mut store,
385
- &self.config,
386
- &[rstring_to_vec(preamble), rstring_to_vec(snippets)],
387
- )?;
388
- let exports = self.instantiate(&ruby, &mut store)?;
389
- let run = frames::require_export(&ruby, exports.run.as_ref())?;
390
- let (env_ptr, env_len) = frames::write_envelope(&ruby, &mut store, &exports, envelope)?;
391
- self.call_with_caps(&mut store, &exports, run, (env_ptr, env_len))
392
- .map_err(|e| trap::call_err(&ruby, e))?;
393
- self.build_snapshot(&ruby, &mut store, &exports)
251
+ let handler = self.build_handler();
252
+ let preamble = rstring_to_vec(preamble);
253
+ let snippets = rstring_to_vec(snippets);
254
+ let envelope = rstring_to_vec(envelope);
255
+ let snapshot = self
256
+ .driver
257
+ .invoke(
258
+ Entry::Run {
259
+ envelope: &envelope,
260
+ },
261
+ Frames {
262
+ preamble: &preamble,
263
+ snippets: &snippets,
264
+ },
265
+ handler,
266
+ )
267
+ .map_err(|e| errors::to_magnus(&ruby, e))?;
268
+ self.finish_invocation(&ruby, snapshot)
269
+ }
270
+
271
+ /// Settle one invocation's `Snapshot` at the Ruby boundary: usage is
272
+ /// recorded even when the completion is a trap and this call raises,
273
+ /// while trap-path captures are deliberately dropped — exposing them
274
+ /// is a SPEC decision the Ruby surface has not taken.
275
+ fn finish_invocation(
276
+ &self,
277
+ ruby: &Ruby,
278
+ snapshot: RuntimeSnapshot,
279
+ ) -> Result<Snapshot, MagnusError> {
280
+ self.last_usage.set(snapshot.usage);
281
+ match snapshot.completion {
282
+ Completion::Outcome(bytes) => {
283
+ Ok(Snapshot::new(bytes, snapshot.stdout, snapshot.stderr))
284
+ }
285
+ Completion::Trap(trap) => Err(errors::trap_to_magnus(ruby, trap)),
286
+ }
287
+ }
288
+
289
+ /// Build the dispatch handler for one invocation from the registered
290
+ /// `on_dispatch` Proc, or `None` when none is set. The `Opaque` the
291
+ /// handler wraps stays GC-rooted by `Runtime`'s `mark`, so the driver
292
+ /// only borrows it for the call (the safety contract on
293
+ /// `kobako_runtime::runtime::Runtime`).
294
+ fn build_handler(&self) -> Option<Arc<dyn DispatchHandler>> {
295
+ self.on_dispatch.get().map(|proc| {
296
+ Arc::new(bridge::RubyDispatchHandler::new(proc)) as Arc<dyn DispatchHandler>
297
+ })
394
298
  }
395
299
 
396
300
  /// Return the per-last-invocation usage as a
@@ -401,176 +305,22 @@ impl Runtime {
401
305
  ///
402
306
  /// * `wall_time` (Float seconds) — the wall-clock duration the
403
307
  /// most recent invocation spent inside the guest export call.
404
- /// Bracket opens in `Runtime::prime_caps` and closes in
405
- /// `disarm_caps`, so the value mirrors the `timeout` deadline
406
- /// accounting and excludes everything that runs after the guest
407
- /// export returns. `0.0` before the first invocation.
308
+ /// The bracket mirrors the `timeout` deadline accounting and
309
+ /// excludes everything that runs after the guest export
310
+ /// returns. `0.0` before the first invocation.
408
311
  /// * `memory_peak` (Integer bytes) — the high-water mark of the
409
312
  /// per-invocation `memory.grow` delta past the linear-memory
410
313
  /// size captured at invocation entry. `0` before the first
411
314
  /// invocation.
412
315
  ///
413
- /// Reads the `last_usage` Cell `build_snapshot` populated before the
414
- /// per-invocation Store was discarded.
415
- pub(crate) fn usage(&self) -> Result<RArray, MagnusError> {
316
+ /// Reads the `last_usage` Cell `finish_invocation` populated from the
317
+ /// returned `Snapshot` before the per-invocation Store was discarded.
318
+ fn usage(&self) -> Result<RArray, MagnusError> {
416
319
  let ruby = Ruby::get().expect("Ruby thread");
417
- let (wall_time, memory_peak) = self.last_usage.get();
320
+ let usage = self.last_usage.get();
418
321
  let arr = ruby.ary_new_capa(2);
419
- arr.push(wall_time)?;
420
- arr.push(memory_peak)?;
322
+ arr.push(usage.wall_time)?;
323
+ arr.push(usage.memory_peak)?;
421
324
  Ok(arr)
422
325
  }
423
-
424
- // -----------------------------------------------------------------
425
- // Private helpers.
426
- // -----------------------------------------------------------------
427
-
428
- /// Build the per-invocation Store: a fresh `Invocation` wired with
429
- /// the memory limiter, the epoch-deadline callback, and the
430
- /// registered dispatch Proc.
431
- fn new_store(&self) -> Result<WtStore<Invocation>, MagnusError> {
432
- let mut store = WtStore::new(shared_engine()?, Invocation::new(self.memory_limit));
433
- store.limiter(|state: &mut Invocation| -> &mut dyn ResourceLimiter { state.limiter_mut() });
434
- store.epoch_deadline_callback(trap::epoch_deadline_callback);
435
- if let Some(on_dispatch) = self.on_dispatch.get() {
436
- store.data_mut().bind_on_dispatch(on_dispatch);
437
- }
438
- Ok(store)
439
- }
440
-
441
- /// Instantiate the per-invocation instance from the pre-linked
442
- /// template and resolve its host-driven export handles. An
443
- /// instantiation failure at invocation time is an engine fault —
444
- /// `Kobako::TrapError` — unlike the construction-time probe, whose
445
- /// failure is `SetupError`.
446
- fn instantiate(
447
- &self,
448
- ruby: &Ruby,
449
- store: &mut WtStore<Invocation>,
450
- ) -> Result<Exports, MagnusError> {
451
- let instance = self
452
- .instance_pre
453
- .instantiate(store.as_context_mut())
454
- .map_err(|e| {
455
- errors::trap_err(
456
- ruby,
457
- format!("failed to instantiate the Sandbox runtime: {e}"),
458
- )
459
- })?;
460
- Ok(Exports::resolve(&instance, store.as_context_mut()))
461
- }
462
-
463
- /// Run one guest export call inside the per-invocation cap window:
464
- /// `Runtime::prime_caps` before, `disarm_caps` after — the shared
465
- /// bracket for both run-path exports (`__kobako_eval` /
466
- /// `__kobako_run`). Disarm runs whether the call returns or traps, so
467
- /// the `wall_time` bracket and the memory
468
- /// cap always close — that close-on-trap guarantee is the reason this
469
- /// bracket lives in one place rather than inline at each call site.
470
- /// The wasmtime trap is returned unmapped; each caller wraps it
471
- /// through `trap::call_err` for its own error context.
472
- fn call_with_caps<Params, Results>(
473
- &self,
474
- store: &mut WtStore<Invocation>,
475
- exports: &Exports,
476
- export: &TypedFunc<Params, Results>,
477
- params: Params,
478
- ) -> Result<Results, wasmtime::Error>
479
- where
480
- Params: wasmtime::WasmParams,
481
- Results: wasmtime::WasmResults,
482
- {
483
- self.prime_caps(store, exports);
484
- let result = export.call(store.as_context_mut(), params);
485
- disarm_caps(store);
486
- // Stash the usage figures on every outcome — including the
487
- // trap paths, where `build_snapshot` never runs and the Store is
488
- // about to be discarded with the error.
489
- let data = store.data();
490
- self.last_usage
491
- .set((data.wall_time().as_secs_f64(), data.memory_peak()));
492
- result
493
- }
494
-
495
- /// Stamp the per-invocation wall-clock deadline into `Invocation`
496
- /// and prime the wasmtime epoch deadline so the next ticker tick
497
- /// wakes the epoch-deadline callback. When `timeout` is disabled,
498
- /// the deadline is set far enough in the future that the callback
499
- /// effectively never fires.
500
- ///
501
- /// Also captures the current linear-memory size as the baseline
502
- /// for the per-invocation memory delta cap —
503
- /// the pre-initialized image's allocation is folded into the
504
- /// baseline rather than the budget — and stamps the wall-clock
505
- /// entry instant for the `wall_time`
506
- /// measurement. The bracket closes in `disarm_caps` so it matches
507
- /// the `timeout` deadline window and excludes `OUTCOME_BUFFER`
508
- /// decoding and stdout / stderr capture readout.
509
- fn prime_caps(&self, store: &mut WtStore<Invocation>, exports: &Exports) {
510
- match self.config.timeout {
511
- Some(timeout) => {
512
- let deadline = Instant::now() + timeout;
513
- store.data_mut().set_deadline(Some(deadline));
514
- store.set_epoch_deadline(1);
515
- }
516
- None => {
517
- store.data_mut().set_deadline(None);
518
- store.set_epoch_deadline(u64::MAX);
519
- }
520
- }
521
- let baseline = match exports.memory {
522
- Some(m) => m.data_size(store.as_context_mut()),
523
- None => 0,
524
- };
525
- store.data_mut().arm_memory_cap(baseline);
526
- store.data_mut().start_wall_clock();
527
- }
528
-
529
- /// Collect every per-invocation observable into a fresh `Snapshot`.
530
- /// Called from the run-path methods after the guest export returns
531
- /// successfully: drains OUTCOME_BUFFER via `__kobako_take_outcome`
532
- /// and snapshots the per-channel stdout / stderr pipes (clipped to
533
- /// their caps). The usage figures were already stashed by
534
- /// `call_with_caps`.
535
- fn build_snapshot(
536
- &self,
537
- ruby: &Ruby,
538
- store: &mut WtStore<Invocation>,
539
- exports: &Exports,
540
- ) -> Result<Snapshot, MagnusError> {
541
- let return_bytes = frames::fetch_outcome_bytes(ruby, store, exports)?;
542
- let data = store.data();
543
- let (stdout_raw, stderr_raw, wall_time, memory_peak) = (
544
- data.stdout_bytes(),
545
- data.stderr_bytes(),
546
- data.wall_time(),
547
- data.memory_peak(),
548
- );
549
- let (stdout_visible, stdout_truncated) =
550
- capture::clip_capture(&stdout_raw, self.config.stdout_limit_bytes);
551
- let stdout_bytes = stdout_visible.to_vec();
552
- let (stderr_visible, stderr_truncated) =
553
- capture::clip_capture(&stderr_raw, self.config.stderr_limit_bytes);
554
- let stderr_bytes = stderr_visible.to_vec();
555
- Ok(Snapshot::new(
556
- return_bytes,
557
- stdout_bytes,
558
- stdout_truncated,
559
- stderr_bytes,
560
- stderr_truncated,
561
- wall_time,
562
- memory_peak,
563
- ))
564
- }
565
- }
566
-
567
- /// Drop the memory cap as soon as the guest call returns so that
568
- /// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
569
- /// which can grow guest memory transiently) is not attributed to
570
- /// the user script. Also closes the
571
- /// `wall_time` bracket opened by `Runtime::prime_caps`. Paired
572
- /// with `Runtime::prime_caps`.
573
- fn disarm_caps(store: &mut WtStore<Invocation>) {
574
- store.data_mut().stop_wall_clock();
575
- store.data_mut().disarm_memory_cap();
576
326
  }