kobako 0.12.1 → 0.13.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +22 -0
  4. data/Cargo.lock +138 -136
  5. data/Cargo.toml +6 -2
  6. data/README.md +3 -1
  7. data/crates/kobako-runtime/CHANGELOG.md +16 -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 +18 -0
  13. data/crates/kobako-runtime/src/profile.rs +35 -0
  14. data/crates/kobako-runtime/src/runtime.rs +57 -0
  15. data/crates/kobako-runtime/src/snapshot.rs +46 -0
  16. data/crates/kobako-runtime/src/yielder.rs +22 -0
  17. data/crates/kobako-wasmtime/CHANGELOG.md +16 -0
  18. data/crates/kobako-wasmtime/Cargo.toml +62 -0
  19. data/crates/kobako-wasmtime/README.md +32 -0
  20. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/ambient.rs +8 -5
  21. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/cache.rs +30 -41
  22. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/capture.rs +2 -2
  23. data/crates/kobako-wasmtime/src/config.rs +33 -0
  24. data/crates/kobako-wasmtime/src/dispatch.rs +110 -0
  25. data/crates/kobako-wasmtime/src/driver.rs +297 -0
  26. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/exports.rs +5 -6
  27. data/crates/kobako-wasmtime/src/frames.rs +276 -0
  28. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/guest_mem.rs +38 -15
  29. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/instance_pre.rs +13 -21
  30. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/invocation.rs +54 -49
  31. data/crates/kobako-wasmtime/src/lib.rs +47 -0
  32. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/trap.rs +29 -35
  33. data/data/kobako.wasm +0 -0
  34. data/ext/kobako/Cargo.toml +9 -32
  35. data/ext/kobako/src/lib.rs +0 -2
  36. data/ext/kobako/src/runtime/bridge.rs +150 -0
  37. data/ext/kobako/src/runtime/errors.rs +45 -13
  38. data/ext/kobako/src/runtime.rs +246 -420
  39. data/lib/kobako/catalog/handles.rb +3 -3
  40. data/lib/kobako/catalog/namespaces.rb +4 -0
  41. data/lib/kobako/catalog/snippets.rb +4 -0
  42. data/lib/kobako/codec/encoder.rb +5 -1
  43. data/lib/kobako/codec/factory.rb +41 -13
  44. data/lib/kobako/codec/handle_walk.rb +4 -0
  45. data/lib/kobako/codec.rb +1 -1
  46. data/lib/kobako/errors.rb +18 -16
  47. data/lib/kobako/sandbox.rb +71 -39
  48. data/lib/kobako/sandbox_options.rb +71 -13
  49. data/lib/kobako/transport/dispatcher.rb +2 -2
  50. data/lib/kobako/transport/response.rb +14 -14
  51. data/lib/kobako/transport/run.rb +2 -6
  52. data/lib/kobako/transport/yield.rb +1 -1
  53. data/lib/kobako/transport/yielder.rb +2 -2
  54. data/lib/kobako/version.rb +1 -1
  55. data/lib/kobako.rb +0 -1
  56. data/release-please-config.json +78 -3
  57. data/sig/kobako/codec/factory.rbs +3 -0
  58. data/sig/kobako/errors.rbs +7 -14
  59. data/sig/kobako/runtime.rbs +16 -6
  60. data/sig/kobako/sandbox.rbs +11 -7
  61. data/sig/kobako/sandbox_options.rbs +15 -4
  62. data/sig/kobako/transport/dispatcher.rbs +1 -1
  63. data/sig/kobako/transport/run.rbs +2 -2
  64. data/sig/kobako/transport/yielder.rbs +2 -2
  65. data/sig/kobako/transport.rbs +8 -0
  66. metadata +28 -15
  67. data/ext/kobako/src/runtime/config.rs +0 -25
  68. data/ext/kobako/src/runtime/dispatch.rs +0 -211
  69. data/ext/kobako/src/runtime/frames.rs +0 -188
  70. data/ext/kobako/src/snapshot.rs +0 -110
  71. data/lib/kobako/snapshot.rb +0 -38
  72. data/sig/kobako/snapshot.rbs +0 -15
@@ -1,82 +1,46 @@
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, profile)`. 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 / capture readouts.
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
-
53
- use magnus::{function, method, prelude::*, Error as MagnusError, RModule, RString, Ruby};
54
-
55
- use std::cell::Cell;
56
- use std::path::Path;
57
- use std::time::{Duration, Instant};
58
-
59
- use magnus::{gc, typed_data::DataTypeFunctions, value::Opaque, RArray, TypedData, Value};
60
28
 
61
- use crate::snapshot::Snapshot;
62
- use wasmtime::{
63
- AsContextMut, InstancePre as WtInstancePre, ResourceLimiter, Store as WtStore, TypedFunc,
29
+ use magnus::{
30
+ function, gc, method, prelude::*, typed_data::DataTypeFunctions, value::Opaque,
31
+ Error as MagnusError, RArray, RModule, RString, Ruby, Symbol, TypedData, Value,
64
32
  };
65
33
 
66
- use self::cache::shared_engine;
67
- use self::config::Config;
68
- use self::exports::Exports;
69
- use self::invocation::Invocation;
34
+ use std::cell::{Cell, RefCell};
35
+ use std::path::Path;
36
+ use std::sync::Arc;
37
+ use std::time::Duration;
70
38
 
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::profile::Profile;
41
+ use kobako_runtime::runtime::{Entry, Frames, Runtime as ContractRuntime};
42
+ use kobako_runtime::snapshot::{Capture, Completion, Snapshot as RuntimeSnapshot, Usage};
43
+ use kobako_wasmtime::{Config, Driver};
80
44
 
81
45
  /// Copy the bytes of `s` into a fresh `Vec<u8>`. Single safe entry to
82
46
  /// what would otherwise be an inline `unsafe { rstring.as_slice() }
@@ -84,11 +48,20 @@ const ABI_VERSION: u32 = 2;
84
48
  /// does not outlive this call, so no Ruby allocation can move the
85
49
  /// underlying RString between the borrow and the copy — the safety
86
50
  /// invariant the inline form relied on is established once here.
87
- pub(crate) fn rstring_to_vec(s: RString) -> Vec<u8> {
51
+ fn rstring_to_vec(s: RString) -> Vec<u8> {
88
52
  // SAFETY: see item doc.
89
53
  unsafe { s.as_slice() }.to_vec()
90
54
  }
91
55
 
56
+ /// The pre-invocation sentinel for one capture channel: no bytes, cap
57
+ /// not reached. Fresh `Vec`s per call because `Capture` owns its buffer.
58
+ fn empty_capture() -> Capture {
59
+ Capture {
60
+ bytes: Vec::new(),
61
+ truncated: false,
62
+ }
63
+ }
64
+
92
65
  // ---------------------------------------------------------------------------
93
66
  // Ruby init
94
67
  // ---------------------------------------------------------------------------
@@ -102,49 +75,47 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
102
75
  // intermediate hierarchy is registered.
103
76
 
104
77
  let runtime = kobako.define_class("Runtime", ruby.class_object())?;
105
- runtime.define_singleton_method("from_path", function!(Runtime::from_path, 5))?;
78
+ runtime.define_singleton_method("from_path", function!(Runtime::from_path, 6))?;
106
79
  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
80
  runtime.define_method("eval", method!(Runtime::eval, 3))?;
112
81
  runtime.define_method("run", method!(Runtime::run, 3))?;
113
82
  runtime.define_method("usage", method!(Runtime::usage, 0))?;
83
+ runtime.define_method("captures", method!(Runtime::captures, 0))?;
84
+ runtime.define_method("profile", method!(Runtime::profile, 0))?;
85
+ // The guest re-enters for a block yield through a frame-scoped
86
+ // `Kobako::Runtime::GuestYielder` the dispatcher hands the Proc, not a
87
+ // method on Runtime.
88
+ bridge::register(runtime)?;
114
89
 
115
90
  Ok(())
116
91
  }
117
92
 
118
93
  #[derive(TypedData)]
119
94
  #[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,
95
+ struct Runtime {
96
+ // The magnus-free wasmtime driver that runs every invocation; the
97
+ // shell only shuttles Ruby values across its boundary.
98
+ driver: Driver,
134
99
  // The host-side dispatch Proc, held here only
135
100
  // 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).
101
+ // Proc across GC. For each invocation `build_handler` wraps a copy of
102
+ // this handle in a `RubyDispatchHandler`, and the driver's `invoke`
103
+ // binds that `Arc<dyn DispatchHandler>` onto the per-invocation
104
+ // `Invocation`, where the `__kobako_dispatch` import calls it both
105
+ // reference the one Proc this `Opaque` pins. `Cell` is sound under the
106
+ // GVL (see the `unsafe impl Sync` below).
141
107
  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
146
- // invocation.
147
- last_usage: Cell<(f64, usize)>,
108
+ // Usage of the most recent invocation, stashed here so `#usage` reads
109
+ // survive the per-invocation Store teardown and the trap path's
110
+ // raise. Zeroed before the first invocation.
111
+ last_usage: Cell<Usage>,
112
+ // Output captures of the most recent invocation, stashed for the same
113
+ // reason as `last_usage`: the trap path raises, and this readout is
114
+ // what keeps the guest's partial output readable after a rescue.
115
+ // `RefCell` (not `Cell`) because `Capture` owns its byte buffer; the
116
+ // same GVL single-thread discipline applies (see the `unsafe impl
117
+ // Sync` below). Empty before the first invocation.
118
+ last_captures: RefCell<(Capture, Capture)>,
148
119
  }
149
120
 
150
121
  impl DataTypeFunctions for Runtime {
@@ -165,11 +136,11 @@ impl DataTypeFunctions for Runtime {
165
136
  }
166
137
 
167
138
  // SAFETY: magnus requires `Send + Sync` on TypedData types. The
168
- // `on_dispatch` / `last_usage` `Cell`s make the auto-derived `Sync`
169
- // unavailable, but every access to them happens under the GVL on a single
170
- // thread at a time — Ruby method calls, and a GC `mark` pass that also
171
- // holds the GVL. No cross-thread access to either Cell can occur. `Send`
172
- // stays auto-derived.
139
+ // `on_dispatch` / `last_usage` `Cell`s and the `last_captures` `RefCell`
140
+ // make the auto-derived `Sync` unavailable, but every access to them
141
+ // happens under the GVL on a single thread at a time — Ruby method calls,
142
+ // and a GC `mark` pass that also holds the GVL. No cross-thread access to
143
+ // any of them can occur. `Send` stays auto-derived.
173
144
  unsafe impl Sync for Runtime {}
174
145
 
175
146
  impl Runtime {
@@ -182,15 +153,18 @@ impl Runtime {
182
153
  /// (`None` disables); `memory_limit` is the linear-memory cap in
183
154
  /// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
184
155
  /// are the per-channel output caps (`None`
185
- /// disables). All four are validated by the caller
186
- /// (`Kobako::Sandbox`); this method only refuses non-finite or
187
- /// non-positive timeouts as a defence in depth.
188
- pub(crate) fn from_path(
156
+ /// disables); `profile` is the isolation rung the driver builds
157
+ /// (`:permissive` / `:hermetic`). All five are validated by the
158
+ /// caller (`Kobako::Sandbox`); this method only refuses non-finite
159
+ /// or non-positive timeouts and off-ladder profiles as a defence in
160
+ /// depth.
161
+ fn from_path(
189
162
  path: String,
190
163
  timeout_seconds: Option<f64>,
191
164
  memory_limit: Option<usize>,
192
165
  stdout_limit_bytes: Option<usize>,
193
166
  stderr_limit_bytes: Option<usize>,
167
+ profile: Symbol,
194
168
  ) -> Result<Self, MagnusError> {
195
169
  let ruby = Ruby::get().expect("Ruby thread");
196
170
  let timeout = match timeout_seconds {
@@ -208,113 +182,54 @@ impl Runtime {
208
182
  ));
209
183
  }
210
184
  };
185
+ // Fail closed on an off-ladder rung: an unrecognized posture must
186
+ // never fall back to a grant. Same defence-in-depth posture as the
187
+ // timeout guard above — `SandboxOptions` is the primary validator.
188
+ let profile = match profile.name()?.as_ref() {
189
+ "hermetic" => Profile::Hermetic,
190
+ "permissive" => Profile::Permissive,
191
+ other => {
192
+ return Err(MagnusError::new(
193
+ ruby.exception_arg_error(),
194
+ format!("profile must be :permissive or :hermetic, got :{other}"),
195
+ ));
196
+ }
197
+ };
211
198
 
212
- let runtime = Self {
213
- instance_pre: instance_pre::cached_instance_pre(Path::new(&path))?,
199
+ let driver = Driver::new(
200
+ Path::new(&path),
214
201
  memory_limit,
215
- config: Config {
202
+ Config {
216
203
  timeout,
217
204
  stdout_limit_bytes,
218
205
  stderr_limit_bytes,
206
+ profile,
219
207
  },
208
+ )
209
+ .map_err(|e| errors::setup_to_magnus(&ruby, e))?;
210
+ Ok(Self {
211
+ driver,
220
212
  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(())
213
+ last_usage: Cell::new(Usage {
214
+ wall_time: 0.0,
215
+ memory_peak: 0,
216
+ }),
217
+ last_captures: RefCell::new((empty_capture(), empty_capture())),
218
+ })
271
219
  }
272
220
 
273
221
  /// Register the Ruby-side dispatch `Proc`.
274
222
  /// 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> {
223
+ /// pinned by `DataTypeFunctions::mark`; for each invocation
224
+ /// `build_handler` wraps a copy in a `RubyDispatchHandler` and the
225
+ /// driver's `invoke` binds it onto the per-invocation `Invocation`,
226
+ /// where the `__kobako_dispatch` import reads it through
227
+ /// `Caller<Invocation>`.
228
+ fn set_on_dispatch(&self, proc_value: Value) -> Result<(), MagnusError> {
279
229
  self.on_dispatch.set(Some(Opaque::from(proc_value)));
280
230
  Ok(())
281
231
  }
282
232
 
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
233
  // -----------------------------------------------------------------
319
234
  // Run-path methods. Each method is best-effort — it raises a Ruby
320
235
  // `Kobako::TrapError` when the corresponding export is missing or
@@ -322,75 +237,107 @@ impl Runtime {
322
237
  // taxonomy.
323
238
  // -----------------------------------------------------------------
324
239
 
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(
240
+ /// One-shot mruby source execution (`#eval`). The Ruby-facing entry:
241
+ /// builds the dispatch handler from the registered Proc, hands the
242
+ /// three stdin frames (`preamble`, `source`, `snippets`) and the source
243
+ /// to the driver, and settles the invocation through
244
+ /// `finish_invocation` or maps a could-not-start `Error` onto its
245
+ /// `Kobako::*` exception. The run mechanics — frames, caps, trap
246
+ /// classification live in `kobako_wasmtime::Driver`.
247
+ fn eval(
342
248
  &self,
343
249
  preamble: RString,
344
250
  source: RString,
345
251
  snippets: RString,
346
- ) -> Result<Snapshot, MagnusError> {
252
+ ) -> Result<RString, MagnusError> {
347
253
  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)
254
+ let handler = self.build_handler();
255
+ let preamble = rstring_to_vec(preamble);
256
+ let source = rstring_to_vec(source);
257
+ let snippets = rstring_to_vec(snippets);
258
+ let snapshot = self
259
+ .driver
260
+ .invoke(
261
+ Entry::Eval { source: &source },
262
+ Frames {
263
+ preamble: &preamble,
264
+ snippets: &snippets,
265
+ },
266
+ handler,
267
+ )
268
+ .map_err(|e| errors::to_magnus(&ruby, e))?;
269
+ self.finish_invocation(&ruby, snapshot)
363
270
  }
364
271
 
365
- /// Execute one entrypoint dispatch (`__kobako_run`) and return a
366
- /// `Snapshot` bundling every per-invocation observable.
272
+ /// Execute one entrypoint dispatch (`__kobako_run`) and return the
273
+ /// guest's raw outcome bytes.
367
274
  ///
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(
275
+ /// The two-frame stdin protocol (preamble + snippets; no user source
276
+ /// frame docs/wire-codec.md § Invocation channels) plus the
277
+ /// `envelope` copied into guest linear memory; cap semantics match
278
+ /// `#eval`. Raises `Kobako::TrapError` / `Kobako::SandboxError` per the
279
+ /// engine-vs-host-fault split inside the driver.
280
+ fn run(
376
281
  &self,
377
282
  preamble: RString,
378
283
  snippets: RString,
379
284
  envelope: RString,
380
- ) -> Result<Snapshot, MagnusError> {
285
+ ) -> Result<RString, MagnusError> {
381
286
  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)
287
+ let handler = self.build_handler();
288
+ let preamble = rstring_to_vec(preamble);
289
+ let snippets = rstring_to_vec(snippets);
290
+ let envelope = rstring_to_vec(envelope);
291
+ let snapshot = self
292
+ .driver
293
+ .invoke(
294
+ Entry::Run {
295
+ envelope: &envelope,
296
+ },
297
+ Frames {
298
+ preamble: &preamble,
299
+ snippets: &snippets,
300
+ },
301
+ handler,
302
+ )
303
+ .map_err(|e| errors::to_magnus(&ruby, e))?;
304
+ self.finish_invocation(&ruby, snapshot)
305
+ }
306
+
307
+ /// Settle one invocation's `Snapshot` at the Ruby boundary: usage and
308
+ /// the two output captures are recorded on every outcome, so the
309
+ /// `#usage` / `#captures` readouts survive the trap path's raise —
310
+ /// that is what keeps the guest's partial output readable after the
311
+ /// Host App rescues the trap. A completed guest invocation returns
312
+ /// its raw outcome bytes; the Sandbox layer decodes them.
313
+ fn finish_invocation(
314
+ &self,
315
+ ruby: &Ruby,
316
+ snapshot: RuntimeSnapshot,
317
+ ) -> Result<RString, MagnusError> {
318
+ let RuntimeSnapshot {
319
+ completion,
320
+ stdout,
321
+ stderr,
322
+ usage,
323
+ } = snapshot;
324
+ self.last_usage.set(usage);
325
+ self.last_captures.replace((stdout, stderr));
326
+ match completion {
327
+ Completion::Outcome(bytes) => Ok(ruby.str_from_slice(&bytes)),
328
+ Completion::Trap(trap) => Err(errors::trap_to_magnus(ruby, trap)),
329
+ }
330
+ }
331
+
332
+ /// Build the dispatch handler for one invocation from the registered
333
+ /// `on_dispatch` Proc, or `None` when none is set. The `Opaque` the
334
+ /// handler wraps stays GC-rooted by `Runtime`'s `mark`, so the driver
335
+ /// only borrows it for the call (the safety contract on
336
+ /// `kobako_runtime::runtime::Runtime`).
337
+ fn build_handler(&self) -> Option<Arc<dyn DispatchHandler>> {
338
+ self.on_dispatch.get().map(|proc| {
339
+ Arc::new(bridge::RubyDispatchHandler::new(proc)) as Arc<dyn DispatchHandler>
340
+ })
394
341
  }
395
342
 
396
343
  /// Return the per-last-invocation usage as a
@@ -401,176 +348,55 @@ impl Runtime {
401
348
  ///
402
349
  /// * `wall_time` (Float seconds) — the wall-clock duration the
403
350
  /// 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.
351
+ /// The bracket mirrors the `timeout` deadline accounting and
352
+ /// excludes everything that runs after the guest export
353
+ /// returns. `0.0` before the first invocation.
408
354
  /// * `memory_peak` (Integer bytes) — the high-water mark of the
409
355
  /// per-invocation `memory.grow` delta past the linear-memory
410
356
  /// size captured at invocation entry. `0` before the first
411
357
  /// invocation.
412
358
  ///
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> {
359
+ /// Reads the `last_usage` Cell `finish_invocation` populated before
360
+ /// the per-invocation Store was discarded.
361
+ fn usage(&self) -> Result<RArray, MagnusError> {
416
362
  let ruby = Ruby::get().expect("Ruby thread");
417
- let (wall_time, memory_peak) = self.last_usage.get();
363
+ let usage = self.last_usage.get();
418
364
  let arr = ruby.ary_new_capa(2);
419
- arr.push(wall_time)?;
420
- arr.push(memory_peak)?;
365
+ arr.push(usage.wall_time)?;
366
+ arr.push(usage.memory_peak)?;
421
367
  Ok(arr)
422
368
  }
423
369
 
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.
370
+ /// Return the per-last-invocation output captures as a Ruby 4-tuple
371
+ /// `[stdout_bytes, stdout_truncated, stderr_bytes, stderr_truncated]`
372
+ /// — the flat positional layout mirrors `#usage`, and the element
373
+ /// order matches the destructure in `Kobako::Sandbox#read_captures!`;
374
+ /// reorder both sides together.
500
375
  ///
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();
376
+ /// Reads the `last_captures` pair `finish_invocation` stashed on
377
+ /// every outcome, so the readout also covers the trap path, where
378
+ /// `#eval` / `#run` raise instead of returning outcome bytes.
379
+ /// Empty bytes and `false` flags before the first invocation.
380
+ fn captures(&self) -> Result<RArray, MagnusError> {
381
+ let ruby = Ruby::get().expect("Ruby thread");
382
+ let captures = self.last_captures.borrow();
383
+ let (stdout, stderr) = &*captures;
384
+ let arr = ruby.ary_new_capa(4);
385
+ arr.push(ruby.str_from_slice(&stdout.bytes))?;
386
+ arr.push(stdout.truncated)?;
387
+ arr.push(ruby.str_from_slice(&stderr.bytes))?;
388
+ arr.push(stderr.truncated)?;
389
+ Ok(arr)
527
390
  }
528
391
 
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
- ))
392
+ /// Return the isolation profile the driver built, as a Symbol
393
+ /// (`:hermetic` / `:permissive`) the declaration the Sandbox
394
+ /// compares against the posture its `profile:` option requested.
395
+ fn profile(&self) -> Symbol {
396
+ let ruby = Ruby::get().expect("Ruby thread");
397
+ match self.driver.profile() {
398
+ Profile::Hermetic => ruby.to_symbol("hermetic"),
399
+ Profile::Permissive => ruby.to_symbol("permissive"),
400
+ }
564
401
  }
565
402
  }
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
- }