kobako 0.18.0 → 0.20.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +31 -0
  4. data/Cargo.lock +6 -5
  5. data/README.md +4 -1
  6. data/crates/kobako-runtime/CHANGELOG.md +14 -0
  7. data/crates/kobako-runtime/Cargo.toml +1 -1
  8. data/crates/kobako-runtime/README.md +1 -1
  9. data/crates/kobako-wasmtime/CHANGELOG.md +14 -0
  10. data/crates/kobako-wasmtime/Cargo.toml +2 -2
  11. data/crates/kobako-wasmtime/README.md +1 -1
  12. data/data/kobako.wasm +0 -0
  13. data/ext/kobako/Cargo.toml +6 -2
  14. data/ext/kobako/src/runtime/bridge.rs +30 -24
  15. data/ext/kobako/src/runtime/gvl.rs +114 -0
  16. data/ext/kobako/src/runtime.rs +211 -190
  17. data/lib/kobako/catalog/extensions.rb +50 -43
  18. data/lib/kobako/catalog/handles.rb +7 -16
  19. data/lib/kobako/catalog/services.rb +22 -36
  20. data/lib/kobako/catalog/snippets.rb +11 -10
  21. data/lib/kobako/context.rb +202 -0
  22. data/lib/kobako/errors.rb +26 -4
  23. data/lib/kobako/execution.rb +52 -0
  24. data/lib/kobako/extension.rb +24 -10
  25. data/lib/kobako/pool.rb +1 -7
  26. data/lib/kobako/sandbox.rb +43 -182
  27. data/lib/kobako/sandbox_options.rb +23 -2
  28. data/lib/kobako/transport/dispatcher.rb +16 -15
  29. data/lib/kobako/transport/yielder.rb +1 -1
  30. data/lib/kobako/unresolved.rb +17 -0
  31. data/lib/kobako/usage.rb +6 -6
  32. data/lib/kobako/version.rb +1 -1
  33. data/lib/kobako.rb +1 -0
  34. data/release-please-config.json +2 -1
  35. data/sig/kobako/catalog/extensions.rbs +4 -6
  36. data/sig/kobako/catalog/handles.rbs +0 -2
  37. data/sig/kobako/catalog/services.rbs +3 -3
  38. data/sig/kobako/catalog/snippets.rbs +2 -0
  39. data/sig/kobako/context.rbs +36 -0
  40. data/sig/kobako/errors.rbs +12 -3
  41. data/sig/kobako/execution.rbs +24 -0
  42. data/sig/kobako/extension.rbs +4 -2
  43. data/sig/kobako/runtime.rbs +37 -9
  44. data/sig/kobako/sandbox.rbs +5 -24
  45. data/sig/kobako/sandbox_options.rbs +9 -2
  46. data/sig/kobako/transport/dispatcher.rbs +3 -3
  47. data/sig/kobako/unresolved.rbs +4 -0
  48. metadata +8 -1
@@ -1,13 +1,16 @@
1
1
  //! Host-side magnus shell over the extracted wasmtime driver.
2
2
  //!
3
- //! The only Ruby-visible class is
3
+ //! The Ruby-visible classes are
4
4
  //!
5
- //! Kobako::Runtime — wraps a `kobako_wasmtime::Driver` + the Ruby seams
5
+ //! Kobako::Runtime — wraps a `kobako_wasmtime::Driver`
6
+ //! Kobako::Runtime::Snapshot — one invocation's completion + captures + usage
6
7
  //!
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 —
8
+ //! `Kobako::Runtime` is constructed via `Kobako::Runtime.from_path(path,
9
+ //! timeout, memory_limit, stdout_limit, stderr_limit, profile)`. Every
10
+ //! invocation (`#eval` / `#run`) takes that run's dispatch handler as a call
11
+ //! argument, instantiates a fresh instance, and returns a `Snapshot` — the
12
+ //! whole per-invocation result — so the Runtime holds no per-invocation
13
+ //! state and one Runtime is safe to drive concurrently. The run mechanics —
11
14
  //! engine/module caches, caps, trap classification — live in the
12
15
  //! `kobako-wasmtime` crate behind the `kobako_runtime` contract; no wasm
13
16
  //! engine type reaches this crate or the Host App.
@@ -17,26 +20,24 @@
17
20
  //! * `bridge` — the magnus dispatch bridge: `RubyDispatchHandler` plus the
18
21
  //! frame-scoped `GuestYielder` Ruby class.
19
22
  //! * `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.
23
+ //! `SetupError` channels onto the `Kobako::*` classes for a failure that
24
+ //! never produced a `Snapshot` (a could-not-start fault).
25
25
 
26
26
  mod bridge;
27
27
  mod errors;
28
+ mod gvl;
28
29
 
29
30
  use magnus::{
30
- function, gc, method, prelude::*, typed_data::DataTypeFunctions, value::Opaque,
31
- Error as MagnusError, RArray, RModule, RString, Ruby, Symbol, TypedData, Value,
31
+ function, method, prelude::*, typed_data::DataTypeFunctions, value::Opaque,
32
+ Error as MagnusError, RModule, RString, Ruby, Symbol, TypedData, Value,
32
33
  };
33
34
 
34
- use std::cell::{Cell, RefCell};
35
35
  use std::path::Path;
36
36
  use std::sync::Arc;
37
37
  use std::time::Duration;
38
38
 
39
39
  use kobako_runtime::dispatch::DispatchHandler;
40
+ use kobako_runtime::error::Trap;
40
41
  use kobako_runtime::profile::Profile;
41
42
  use kobako_runtime::runtime::{Entry, Frames, Runtime as ContractRuntime};
42
43
  use kobako_runtime::snapshot::{Capture, Completion, Snapshot as RuntimeSnapshot, Usage};
@@ -63,73 +64,49 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
63
64
  // in `runtime/errors.rs` — no intermediate hierarchy is registered.
64
65
 
65
66
  let runtime = kobako.define_class("Runtime", ruby.class_object())?;
66
- runtime.define_singleton_method("from_path", function!(Runtime::from_path, 6))?;
67
- runtime.define_method("on_dispatch=", method!(Runtime::set_on_dispatch, 1))?;
68
- runtime.define_method("eval", method!(Runtime::eval, 3))?;
69
- runtime.define_method("run", method!(Runtime::run, 3))?;
70
- runtime.define_method("usage", method!(Runtime::usage, 0))?;
71
- runtime.define_method("captures", method!(Runtime::captures, 0))?;
67
+ runtime.define_singleton_method("from_path", function!(Runtime::from_path, 7))?;
68
+ runtime.define_method("eval", method!(Runtime::eval, 4))?;
69
+ runtime.define_method("run", method!(Runtime::run, 4))?;
72
70
  runtime.define_method("profile", method!(Runtime::profile, 0))?;
73
71
  // The guest re-enters for a block yield through a frame-scoped
74
72
  // `Kobako::Runtime::GuestYielder` the dispatcher hands the Proc, not a
75
73
  // method on Runtime.
76
74
  bridge::register(runtime)?;
77
75
 
76
+ // Snapshot — the per-invocation result object each entry point returns.
77
+ let snapshot = runtime.define_class("Snapshot", ruby.class_object())?;
78
+ snapshot.define_method("outcome", method!(Snapshot::outcome, 0))?;
79
+ snapshot.define_method("trapped?", method!(Snapshot::trapped, 0))?;
80
+ snapshot.define_method("trap_kind", method!(Snapshot::trap_kind, 0))?;
81
+ snapshot.define_method("trap_message", method!(Snapshot::trap_message, 0))?;
82
+ snapshot.define_method("wall_time", method!(Snapshot::wall_time, 0))?;
83
+ snapshot.define_method("memory_peak", method!(Snapshot::memory_peak, 0))?;
84
+ snapshot.define_method("stdout", method!(Snapshot::stdout, 0))?;
85
+ snapshot.define_method("stdout_truncated?", method!(Snapshot::stdout_truncated, 0))?;
86
+ snapshot.define_method("stderr", method!(Snapshot::stderr, 0))?;
87
+ snapshot.define_method("stderr_truncated?", method!(Snapshot::stderr_truncated, 0))?;
88
+
78
89
  Ok(())
79
90
  }
80
91
 
81
92
  #[derive(TypedData)]
82
- #[magnus(class = "Kobako::Runtime", free_immediately, size, mark)]
93
+ #[magnus(class = "Kobako::Runtime", free_immediately, size)]
83
94
  struct Runtime {
84
95
  // The magnus-free wasmtime driver that runs every invocation; the
85
- // shell only shuttles Ruby values across its boundary.
96
+ // shell only shuttles Ruby values across its boundary. The Runtime
97
+ // holds no per-invocation state — each `#eval` / `#run` takes its
98
+ // dispatch handler as an argument and returns its whole result as a
99
+ // `Snapshot` — so `Driver`'s own `Send + Sync` carries the type with no
100
+ // interior mutability to guard.
86
101
  driver: Driver,
87
- // The host-side dispatch Proc, held here only
88
- // to give `DataTypeFunctions::mark` a read path so it can pin the
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).
95
- on_dispatch: Cell<Option<Opaque<Value>>>,
96
- // Usage of the most recent invocation, stashed here so `#usage` reads
97
- // survive the per-invocation Store teardown and the trap path's
98
- // raise. Zeroed before the first invocation.
99
- last_usage: Cell<Usage>,
100
- // Output captures of the most recent invocation, stashed for the same
101
- // reason as `last_usage`: the trap path raises, and this readout is
102
- // what keeps the guest's partial output readable after a rescue.
103
- // `RefCell` (not `Cell`) because `Capture` owns its byte buffer; the
104
- // same GVL single-thread discipline applies (see the `unsafe impl
105
- // Sync` below). Empty before the first invocation.
106
- last_captures: RefCell<(Capture, Capture)>,
102
+ // Whether each invocation releases Ruby's GVL for its guest span
103
+ // (`gvl: :release`) or holds it throughout (`gvl: :hold`). Fixed at
104
+ // construction; a `bool` carries no interior mutability, so the type
105
+ // stays `Send + Sync`.
106
+ release_gvl: bool,
107
107
  }
108
108
 
109
- impl DataTypeFunctions for Runtime {
110
- /// Mark — and thereby pin — the host-side dispatch Proc so Ruby's GC
111
- /// neither collects nor moves it while the ext holds a raw `Opaque`
112
- /// copy on `Invocation` for the duration of a guest invocation.
113
- /// `gc::Marker::mark` maps to `rb_gc_mark`, which pins: required because
114
- /// the Invocation copy is a cached `VALUE` that compaction would
115
- /// otherwise leave dangling. Without
116
- /// this the Proc has no GC root at all — sweep collects it (SIGSEGV on
117
- /// the next dispatch) and compaction relocates it (dispatch lands on
118
- /// the wrong receiver).
119
- fn mark(&self, marker: &gc::Marker) {
120
- if let Some(on_dispatch) = self.on_dispatch.get() {
121
- marker.mark(on_dispatch);
122
- }
123
- }
124
- }
125
-
126
- // SAFETY: magnus requires `Send + Sync` on TypedData types. The
127
- // `on_dispatch` / `last_usage` `Cell`s and the `last_captures` `RefCell`
128
- // make the auto-derived `Sync` unavailable, but every access to them
129
- // happens under the GVL on a single thread at a time — Ruby method calls,
130
- // and a GC `mark` pass that also holds the GVL. No cross-thread access to
131
- // any of them can occur. `Send` stays auto-derived.
132
- unsafe impl Sync for Runtime {}
109
+ impl DataTypeFunctions for Runtime {}
133
110
 
134
111
  impl Runtime {
135
112
  /// Construct a Runtime from a wasm file path, using the process-wide
@@ -142,10 +119,12 @@ impl Runtime {
142
119
  /// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
143
120
  /// are the per-channel output caps (`None`
144
121
  /// disables); `profile` is the isolation rung the driver builds
145
- /// (`:permissive` / `:hermetic`). All five are validated by the
146
- /// caller (`Kobako::Sandbox`); this method only refuses non-finite
147
- /// or non-positive timeouts and off-ladder profiles as a defence in
148
- /// depth.
122
+ /// (`:permissive` / `:hermetic`); `gvl` is the scheduling mode
123
+ /// (`:hold` / `:release`) deciding whether each invocation releases
124
+ /// the GVL for its guest span. All six are validated by the caller
125
+ /// (`Kobako::Sandbox`); this method only refuses non-finite or
126
+ /// non-positive timeouts, off-ladder profiles, and unrecognized gvl
127
+ /// modes as a defence in depth.
149
128
  fn from_path(
150
129
  path: String,
151
130
  timeout_seconds: Option<f64>,
@@ -153,6 +132,7 @@ impl Runtime {
153
132
  stdout_limit_bytes: Option<usize>,
154
133
  stderr_limit_bytes: Option<usize>,
155
134
  profile: Symbol,
135
+ gvl: Symbol,
156
136
  ) -> Result<Self, MagnusError> {
157
137
  let ruby = Ruby::get().expect("Ruby thread");
158
138
  let timeout = match timeout_seconds {
@@ -183,6 +163,19 @@ impl Runtime {
183
163
  ));
184
164
  }
185
165
  };
166
+ // Same fail-closed posture as `profile`: an unrecognized mode raises
167
+ // rather than defaulting. `SandboxOptions` is the primary validator;
168
+ // this guards direct `from_path` calls.
169
+ let release_gvl = match gvl.name()?.as_ref() {
170
+ "hold" => false,
171
+ "release" => true,
172
+ other => {
173
+ return Err(MagnusError::new(
174
+ ruby.exception_arg_error(),
175
+ format!("gvl must be :hold or :release, got :{other}"),
176
+ ));
177
+ }
178
+ };
186
179
 
187
180
  let driver = Driver::new(
188
181
  Path::new(&path),
@@ -197,52 +190,39 @@ impl Runtime {
197
190
  .map_err(|e| errors::setup_to_magnus(&ruby, e))?;
198
191
  Ok(Self {
199
192
  driver,
200
- on_dispatch: Cell::new(None),
201
- last_usage: Cell::new(Usage::default()),
202
- last_captures: RefCell::new((Capture::default(), Capture::default())),
193
+ release_gvl,
203
194
  })
204
195
  }
205
196
 
206
- /// Register the Ruby-side dispatch `Proc`.
207
- /// Bound to Ruby as `Kobako::Runtime#on_dispatch=`. The handle is
208
- /// pinned by `DataTypeFunctions::mark`; for each invocation
209
- /// `build_handler` wraps a copy in a `RubyDispatchHandler` and the
210
- /// driver's `invoke` binds it onto the per-invocation `Invocation`,
211
- /// where the `__kobako_dispatch` import reads it through
212
- /// `Caller<Invocation>`.
213
- fn set_on_dispatch(&self, proc_value: Value) -> Result<(), MagnusError> {
214
- self.on_dispatch.set(Some(Opaque::from(proc_value)));
215
- Ok(())
216
- }
217
-
218
197
  // -----------------------------------------------------------------
219
- // Run-path methods. Each method is best-effort — it raises a Ruby
220
- // `Kobako::TrapError` when the corresponding export is missing or
221
- // fails so the Sandbox layer can map errors to the three-class
222
- // taxonomy.
198
+ // Run-path methods. Each takes the run's dispatch handler as its first
199
+ // argument and returns a `Snapshot` for any completed invocation —
200
+ // success or trap alike. Only a could-not-start fault (a missing export
201
+ // or a fault before the export call) raises a `Kobako::TrapError`
202
+ // directly, since it yields no `Snapshot`.
223
203
  // -----------------------------------------------------------------
224
204
 
225
- /// One-shot mruby source execution (`#eval`). The Ruby-facing entry:
226
- /// builds the dispatch handler from the registered Proc, hands the
227
- /// three stdin frames (`preamble`, `source`, `snippets`) and the source
228
- /// to the driver, and settles the invocation through
229
- /// `finish_invocation` — or maps a could-not-start `Error` onto its
230
- /// `Kobako::*` exception. The run mechanics — frames, caps, trap
231
- /// classification — live in `kobako_wasmtime::Driver`.
205
+ /// One-shot mruby source execution (`#eval`). Builds the dispatch
206
+ /// handler from `dispatch` (the per-invocation Proc), hands the three
207
+ /// stdin frames (`preamble`, `source`, `snippets`) and the source to the
208
+ /// driver, and returns the run's `Snapshot`.
232
209
  fn eval(
233
210
  &self,
211
+ dispatch: Value,
234
212
  preamble: RString,
235
213
  source: RString,
236
214
  snippets: RString,
237
- ) -> Result<RString, MagnusError> {
215
+ ) -> Result<Snapshot, MagnusError> {
238
216
  let ruby = Ruby::get().expect("Ruby thread");
239
- let handler = self.build_handler();
217
+ let handler = build_handler(dispatch);
240
218
  let preamble = rstring_to_vec(preamble);
241
219
  let source = rstring_to_vec(source);
242
220
  let snippets = rstring_to_vec(snippets);
243
- let snapshot = self
244
- .driver
245
- .invoke(
221
+ // Release the GVL around the guest span iff this Sandbox asks for it;
222
+ // the closure touches no Ruby VALUE (the driver is magnus-free, and a
223
+ // guest→host dispatch re-acquires the GVL through the bridge).
224
+ let result = gvl::region(self.release_gvl, || {
225
+ self.driver.invoke(
246
226
  Entry::Eval { source: &source },
247
227
  Frames {
248
228
  preamble: &preamble,
@@ -250,32 +230,34 @@ impl Runtime {
250
230
  },
251
231
  handler,
252
232
  )
253
- .map_err(|e| errors::to_magnus(&ruby, e))?;
254
- self.finish_invocation(&ruby, snapshot)
233
+ });
234
+ let snapshot = result.map_err(|e| errors::to_magnus(&ruby, e))?;
235
+ Ok(Snapshot::from(snapshot))
255
236
  }
256
237
 
257
- /// Execute one entrypoint dispatch (`__kobako_run`) and return the
258
- /// guest's raw outcome bytes.
238
+ /// Execute one entrypoint dispatch (`__kobako_run`) and return its
239
+ /// `Snapshot`.
259
240
  ///
260
241
  /// The two-frame stdin protocol (preamble + snippets; no user source
261
242
  /// frame — docs/wire-codec.md § Invocation channels) plus the
262
243
  /// `envelope` copied into guest linear memory; cap semantics match
263
- /// `#eval`. Raises `Kobako::TrapError` / `Kobako::SandboxError` per the
264
- /// engine-vs-host-fault split inside the driver.
244
+ /// `#eval`.
265
245
  fn run(
266
246
  &self,
247
+ dispatch: Value,
267
248
  preamble: RString,
268
249
  snippets: RString,
269
250
  envelope: RString,
270
- ) -> Result<RString, MagnusError> {
251
+ ) -> Result<Snapshot, MagnusError> {
271
252
  let ruby = Ruby::get().expect("Ruby thread");
272
- let handler = self.build_handler();
253
+ let handler = build_handler(dispatch);
273
254
  let preamble = rstring_to_vec(preamble);
274
255
  let snippets = rstring_to_vec(snippets);
275
256
  let envelope = rstring_to_vec(envelope);
276
- let snapshot = self
277
- .driver
278
- .invoke(
257
+ // Release the GVL around the guest span iff this Sandbox asks for it;
258
+ // see the note in `#eval`.
259
+ let result = gvl::region(self.release_gvl, || {
260
+ self.driver.invoke(
279
261
  Entry::Run {
280
262
  envelope: &envelope,
281
263
  },
@@ -285,103 +267,142 @@ impl Runtime {
285
267
  },
286
268
  handler,
287
269
  )
288
- .map_err(|e| errors::to_magnus(&ruby, e))?;
289
- self.finish_invocation(&ruby, snapshot)
270
+ });
271
+ let snapshot = result.map_err(|e| errors::to_magnus(&ruby, e))?;
272
+ Ok(Snapshot::from(snapshot))
290
273
  }
291
274
 
292
- /// Settle one invocation's `Snapshot` at the Ruby boundary: usage and
293
- /// the two output captures are recorded on every outcome, so the
294
- /// `#usage` / `#captures` readouts survive the trap path's raise —
295
- /// that is what keeps the guest's partial output readable after the
296
- /// Host App rescues the trap. A completed guest invocation returns
297
- /// its raw outcome bytes; the Sandbox layer decodes them.
298
- fn finish_invocation(
299
- &self,
300
- ruby: &Ruby,
301
- snapshot: RuntimeSnapshot,
302
- ) -> Result<RString, MagnusError> {
275
+ /// Return the isolation profile the driver built, as a Symbol
276
+ /// (`:hermetic` / `:permissive`) — the declaration the Sandbox
277
+ /// compares against the posture its `profile:` option requested.
278
+ fn profile(&self) -> Symbol {
279
+ let ruby = Ruby::get().expect("Ruby thread");
280
+ match self.driver.profile() {
281
+ Profile::Hermetic => ruby.to_symbol("hermetic"),
282
+ Profile::Permissive => ruby.to_symbol("permissive"),
283
+ }
284
+ }
285
+ }
286
+
287
+ /// Build the dispatch handler for one invocation from the per-call `dispatch`
288
+ /// Proc. A `nil` Proc yields no handler. The Proc stays GC-rooted for the
289
+ /// duration of the synchronous `#eval` / `#run` call as a live method
290
+ /// argument on the Ruby stack, so the driver only borrows it (the safety
291
+ /// contract on `kobako_runtime::runtime::Runtime`).
292
+ fn build_handler(dispatch: Value) -> Option<Arc<dyn DispatchHandler>> {
293
+ if dispatch.is_nil() {
294
+ return None;
295
+ }
296
+ Some(
297
+ Arc::new(bridge::RubyDispatchHandler::new(Opaque::from(dispatch)))
298
+ as Arc<dyn DispatchHandler>,
299
+ )
300
+ }
301
+
302
+ /// One invocation's result at the Ruby boundary — the whole `Snapshot` the
303
+ /// driver produced, exposed as `Kobako::Runtime::Snapshot`. Usage and the
304
+ /// two output captures are present on every outcome, so the trap path
305
+ /// carries them just like the value path; the completion is read as either
306
+ /// the outcome bytes (`#outcome`) or a trap (`#trapped?` / `#trap_kind` /
307
+ /// `#trap_message`), and the Sandbox layer maps a trap onto its
308
+ /// `Kobako::TrapError` family.
309
+ #[derive(TypedData)]
310
+ #[magnus(class = "Kobako::Runtime::Snapshot", free_immediately, size)]
311
+ struct Snapshot {
312
+ completion: Completion,
313
+ stdout: Capture,
314
+ stderr: Capture,
315
+ usage: Usage,
316
+ }
317
+
318
+ impl DataTypeFunctions for Snapshot {}
319
+
320
+ impl From<RuntimeSnapshot> for Snapshot {
321
+ fn from(snapshot: RuntimeSnapshot) -> Self {
303
322
  let RuntimeSnapshot {
304
323
  completion,
305
324
  stdout,
306
325
  stderr,
307
326
  usage,
308
327
  } = snapshot;
309
- self.last_usage.set(usage);
310
- self.last_captures.replace((stdout, stderr));
311
- match completion {
312
- Completion::Outcome(bytes) => Ok(ruby.str_from_slice(&bytes)),
313
- Completion::Trap(trap) => Err(errors::trap_to_magnus(ruby, trap)),
328
+ Self {
329
+ completion,
330
+ stdout,
331
+ stderr,
332
+ usage,
314
333
  }
315
334
  }
335
+ }
316
336
 
317
- /// Build the dispatch handler for one invocation from the registered
318
- /// `on_dispatch` Proc, or `None` when none is set. The `Opaque` the
319
- /// handler wraps stays GC-rooted by `Runtime`'s `mark`, so the driver
320
- /// only borrows it for the call (the safety contract on
321
- /// `kobako_runtime::runtime::Runtime`).
322
- fn build_handler(&self) -> Option<Arc<dyn DispatchHandler>> {
323
- self.on_dispatch.get().map(|proc| {
324
- Arc::new(bridge::RubyDispatchHandler::new(proc)) as Arc<dyn DispatchHandler>
325
- })
337
+ impl Snapshot {
338
+ /// The guest's raw outcome bytes on a completed run; empty on a trap,
339
+ /// where `#trapped?` is the authoritative discriminator and the bytes
340
+ /// are never read.
341
+ fn outcome(&self) -> RString {
342
+ let ruby = Ruby::get().expect("Ruby thread");
343
+ match &self.completion {
344
+ Completion::Outcome(bytes) => ruby.str_from_slice(bytes),
345
+ Completion::Trap(_) => ruby.str_from_slice(&[]),
346
+ }
326
347
  }
327
348
 
328
- /// Return the per-last-invocation usage as a
329
- /// Ruby 2-tuple `[wall_time, memory_peak]`. The element order
330
- /// matches the `Kobako::Usage` field order declared in
331
- /// `lib/kobako/usage.rb`; reorder both sides together if the field
332
- /// list ever grows.
333
- ///
334
- /// * `wall_time` (Float seconds) — the wall-clock duration the
335
- /// most recent invocation spent inside the guest export call.
336
- /// The bracket mirrors the `timeout` deadline accounting and
337
- /// excludes everything that runs after the guest export
338
- /// returns. `0.0` before the first invocation.
339
- /// * `memory_peak` (Integer bytes) — the high-water mark of the
340
- /// per-invocation `memory.grow` delta past the linear-memory
341
- /// size captured at invocation entry. `0` before the first
342
- /// invocation.
343
- ///
344
- /// Reads the `last_usage` Cell `finish_invocation` populated before
345
- /// the per-invocation Store was discarded.
346
- fn usage(&self) -> Result<RArray, MagnusError> {
349
+ /// `true` iff the invocation completed via an engine trap.
350
+ fn trapped(&self) -> bool {
351
+ matches!(self.completion, Completion::Trap(_))
352
+ }
353
+
354
+ /// The trap's neutral kind as a Symbol (`:timeout` / `:memory_limit` /
355
+ /// `:trap`), or `nil` on a completed run. The Sandbox maps this onto the
356
+ /// named `Kobako::TrapError` subclass.
357
+ fn trap_kind(&self) -> Option<Symbol> {
347
358
  let ruby = Ruby::get().expect("Ruby thread");
348
- let usage = self.last_usage.get();
349
- let arr = ruby.ary_new_capa(2);
350
- arr.push(usage.wall_time)?;
351
- arr.push(usage.memory_peak)?;
352
- Ok(arr)
359
+ match &self.completion {
360
+ Completion::Trap(Trap::Timeout(_)) => Some(ruby.to_symbol("timeout")),
361
+ Completion::Trap(Trap::MemoryLimit(_)) => Some(ruby.to_symbol("memory_limit")),
362
+ Completion::Trap(Trap::Other(_)) => Some(ruby.to_symbol("trap")),
363
+ Completion::Outcome(_) => None,
364
+ }
353
365
  }
354
366
 
355
- /// Return the per-last-invocation output captures as a Ruby 4-tuple
356
- /// `[stdout_bytes, stdout_truncated, stderr_bytes, stderr_truncated]`
357
- /// — the flat positional layout mirrors `#usage`, and the element
358
- /// order matches the destructure in `Kobako::Sandbox#read_captures!`;
359
- /// reorder both sides together.
360
- ///
361
- /// Reads the `last_captures` pair `finish_invocation` stashed on
362
- /// every outcome, so the readout also covers the trap path, where
363
- /// `#eval` / `#run` raise instead of returning outcome bytes.
364
- /// Empty bytes and `false` flags before the first invocation.
365
- fn captures(&self) -> Result<RArray, MagnusError> {
367
+ /// The trap's message, or `nil` on a completed run.
368
+ fn trap_message(&self) -> Option<String> {
369
+ match &self.completion {
370
+ Completion::Trap(Trap::Timeout(msg) | Trap::MemoryLimit(msg) | Trap::Other(msg)) => {
371
+ Some(msg.clone())
372
+ }
373
+ Completion::Outcome(_) => None,
374
+ }
375
+ }
376
+
377
+ /// Wall-clock seconds the guest export call spent inside wasmtime.
378
+ fn wall_time(&self) -> f64 {
379
+ self.usage.wall_time
380
+ }
381
+
382
+ /// High-water `memory.grow` delta in bytes past the entry-time baseline.
383
+ fn memory_peak(&self) -> usize {
384
+ self.usage.memory_peak
385
+ }
386
+
387
+ /// Bytes captured on the guest's stdout channel, clipped to the cap.
388
+ fn stdout(&self) -> RString {
366
389
  let ruby = Ruby::get().expect("Ruby thread");
367
- let captures = self.last_captures.borrow();
368
- let (stdout, stderr) = &*captures;
369
- let arr = ruby.ary_new_capa(4);
370
- arr.push(ruby.str_from_slice(&stdout.bytes))?;
371
- arr.push(stdout.truncated)?;
372
- arr.push(ruby.str_from_slice(&stderr.bytes))?;
373
- arr.push(stderr.truncated)?;
374
- Ok(arr)
390
+ ruby.str_from_slice(&self.stdout.bytes)
375
391
  }
376
392
 
377
- /// Return the isolation profile the driver built, as a Symbol
378
- /// (`:hermetic` / `:permissive`) — the declaration the Sandbox
379
- /// compares against the posture its `profile:` option requested.
380
- fn profile(&self) -> Symbol {
393
+ /// `true` iff the stdout channel reached its cap during this run.
394
+ fn stdout_truncated(&self) -> bool {
395
+ self.stdout.truncated
396
+ }
397
+
398
+ /// Bytes captured on the guest's stderr channel, clipped to the cap.
399
+ fn stderr(&self) -> RString {
381
400
  let ruby = Ruby::get().expect("Ruby thread");
382
- match self.driver.profile() {
383
- Profile::Hermetic => ruby.to_symbol("hermetic"),
384
- Profile::Permissive => ruby.to_symbol("permissive"),
385
- }
401
+ ruby.str_from_slice(&self.stderr.bytes)
402
+ }
403
+
404
+ /// `true` iff the stderr channel reached its cap during this run.
405
+ fn stderr_truncated(&self) -> bool {
406
+ self.stderr.truncated
386
407
  }
387
408
  }