kobako 0.19.0 → 0.21.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +127 -0
  4. data/Cargo.lock +15 -5
  5. data/README.md +96 -44
  6. data/ROADMAP.md +1 -1
  7. data/crates/kobako-runtime/CHANGELOG.md +44 -0
  8. data/crates/kobako-runtime/Cargo.toml +8 -1
  9. data/crates/kobako-runtime/README.md +1 -1
  10. data/crates/kobako-runtime/src/dispatch.rs +17 -11
  11. data/crates/kobako-runtime/src/error.rs +38 -6
  12. data/crates/kobako-runtime/src/lib.rs +13 -2
  13. data/crates/kobako-runtime/src/profile.rs +8 -0
  14. data/crates/kobako-runtime/src/runtime.rs +7 -2
  15. data/crates/kobako-runtime/src/snapshot.rs +13 -4
  16. data/crates/kobako-runtime/src/yielder.rs +4 -4
  17. data/crates/kobako-transport/CHANGELOG.md +31 -0
  18. data/crates/kobako-transport/Cargo.toml +27 -0
  19. data/crates/kobako-transport/README.md +43 -0
  20. data/crates/kobako-transport/src/abi.rs +89 -0
  21. data/crates/kobako-transport/src/envelope/bytes.rs +234 -0
  22. data/crates/kobako-transport/src/envelope/call.rs +222 -0
  23. data/crates/kobako-transport/src/envelope/error_record.rs +101 -0
  24. data/crates/kobako-transport/src/envelope/fault.rs +171 -0
  25. data/crates/kobako-transport/src/envelope/invocation_frames.rs +232 -0
  26. data/crates/kobako-transport/src/envelope/outcome.rs +261 -0
  27. data/crates/kobako-transport/src/envelope/reply.rs +240 -0
  28. data/crates/kobako-transport/src/envelope/run.rs +85 -0
  29. data/crates/kobako-transport/src/envelope.rs +73 -0
  30. data/crates/kobako-transport/src/lib.rs +21 -0
  31. data/crates/kobako-wasmtime/CHANGELOG.md +30 -0
  32. data/crates/kobako-wasmtime/Cargo.toml +5 -2
  33. data/crates/kobako-wasmtime/README.md +1 -1
  34. data/crates/kobako-wasmtime/src/config.rs +19 -12
  35. data/crates/kobako-wasmtime/src/dispatch.rs +15 -8
  36. data/crates/kobako-wasmtime/src/driver.rs +8 -27
  37. data/crates/kobako-wasmtime/src/frames.rs +11 -9
  38. data/crates/kobako-wasmtime/src/guest_mem.rs +7 -15
  39. data/crates/kobako-wasmtime/src/instance_pre.rs +2 -2
  40. data/crates/kobako-wasmtime/src/invocation.rs +1 -1
  41. data/crates/kobako-wasmtime/src/lib.rs +1 -1
  42. data/data/kobako.wasm +0 -0
  43. data/ext/kobako/Cargo.toml +7 -2
  44. data/ext/kobako/src/runtime/bridge.rs +103 -40
  45. data/ext/kobako/src/runtime/errors.rs +13 -5
  46. data/ext/kobako/src/runtime/gvl.rs +114 -0
  47. data/ext/kobako/src/runtime.rs +307 -207
  48. data/lib/kobako/catalog/extensions.rb +50 -43
  49. data/lib/kobako/catalog/handles.rb +7 -16
  50. data/lib/kobako/catalog/services.rb +24 -46
  51. data/lib/kobako/catalog/snippets.rb +16 -33
  52. data/lib/kobako/codec/decoder.rb +10 -13
  53. data/lib/kobako/codec/encoder.rb +7 -7
  54. data/lib/kobako/codec/error.rb +6 -6
  55. data/lib/kobako/codec/ext_types.rb +12 -72
  56. data/lib/kobako/codec/handle_walk.rb +6 -6
  57. data/lib/kobako/codec/state.rb +4 -56
  58. data/lib/kobako/codec/utils.rb +4 -4
  59. data/lib/kobako/codec.rb +11 -21
  60. data/lib/kobako/context.rb +208 -0
  61. data/lib/kobako/errors.rb +59 -16
  62. data/lib/kobako/execution.rb +52 -0
  63. data/lib/kobako/extension.rb +24 -10
  64. data/lib/kobako/outcome.rb +71 -128
  65. data/lib/kobako/payload/arguments.rb +59 -0
  66. data/lib/kobako/payload.rb +19 -0
  67. data/lib/kobako/pool.rb +1 -7
  68. data/lib/kobako/sandbox.rb +51 -191
  69. data/lib/kobako/sandbox_options.rb +24 -3
  70. data/lib/kobako/snippet/binary.rb +3 -4
  71. data/lib/kobako/snippet/source.rb +5 -6
  72. data/lib/kobako/snippet.rb +2 -2
  73. data/lib/kobako/transport/call.rb +21 -0
  74. data/lib/kobako/transport/dispatcher.rb +94 -142
  75. data/lib/kobako/transport/error.rb +21 -5
  76. data/lib/kobako/transport/reflection.rb +82 -0
  77. data/lib/kobako/transport/run.rb +25 -33
  78. data/lib/kobako/transport/yielder.rb +27 -36
  79. data/lib/kobako/transport.rb +11 -8
  80. data/lib/kobako/unresolved.rb +17 -0
  81. data/lib/kobako/usage.rb +7 -7
  82. data/lib/kobako/version.rb +1 -1
  83. data/lib/kobako.rb +1 -0
  84. data/release-please-config.json +47 -2
  85. data/sig/kobako/catalog/extensions.rbs +4 -6
  86. data/sig/kobako/catalog/handles.rbs +0 -2
  87. data/sig/kobako/catalog/services.rbs +4 -4
  88. data/sig/kobako/catalog/snippets.rbs +4 -2
  89. data/sig/kobako/codec/error.rbs +4 -4
  90. data/sig/kobako/codec/ext_types.rbs +0 -7
  91. data/sig/kobako/codec/state.rbs +0 -4
  92. data/sig/kobako/codec.rbs +4 -5
  93. data/sig/kobako/context.rbs +36 -0
  94. data/sig/kobako/errors.rbs +27 -6
  95. data/sig/kobako/execution.rbs +24 -0
  96. data/sig/kobako/extension.rbs +4 -2
  97. data/sig/kobako/outcome.rbs +12 -13
  98. data/sig/kobako/payload/arguments.rbs +26 -0
  99. data/sig/kobako/payload.rbs +4 -0
  100. data/sig/kobako/pool.rbs +0 -2
  101. data/sig/kobako/runtime.rbs +44 -14
  102. data/sig/kobako/sandbox.rbs +5 -24
  103. data/sig/kobako/sandbox_options.rbs +9 -2
  104. data/sig/kobako/snippet/binary.rbs +1 -1
  105. data/sig/kobako/snippet/source.rbs +1 -1
  106. data/sig/kobako/transport/call.rbs +21 -0
  107. data/sig/kobako/transport/dispatcher.rbs +9 -11
  108. data/sig/kobako/transport/error.rbs +11 -0
  109. data/sig/kobako/transport/reflection.rbs +15 -0
  110. data/sig/kobako/transport/run.rbs +1 -1
  111. data/sig/kobako/transport/yielder.rbs +2 -2
  112. data/sig/kobako/transport.rbs +8 -2
  113. data/sig/kobako/unresolved.rbs +4 -0
  114. metadata +30 -11
  115. data/lib/kobako/fault.rb +0 -38
  116. data/lib/kobako/outcome/panic.rb +0 -42
  117. data/lib/kobako/transport/request.rb +0 -76
  118. data/lib/kobako/transport/response.rb +0 -83
  119. data/lib/kobako/transport/yield.rb +0 -94
  120. data/sig/kobako/fault.rbs +0 -19
  121. data/sig/kobako/outcome/panic.rbs +0 -34
  122. data/sig/kobako/transport/request.rbs +0 -29
  123. data/sig/kobako/transport/response.rbs +0 -33
  124. data/sig/kobako/transport/yield.rbs +0 -34
@@ -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,31 +20,36 @@
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
+ function, method, prelude::*, typed_data::DataTypeFunctions, value::Opaque,
31
32
  Error as MagnusError, RArray, 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};
44
+ use kobako_transport::envelope::{Bindings, Outcome, Run, Snippet, Snippets};
43
45
  use kobako_wasmtime::{Config, Driver};
44
46
 
47
+ /// A Panic's fields as they cross to Ruby: origin, class, message,
48
+ /// backtrace, and the names the invocation could have used in place of the
49
+ /// one it named. Named so the Ruby side reads them positionally against one
50
+ /// shape, matching the `Kobako::Outcome::panic_fields` alias.
51
+ type PanicFields = (String, String, String, Vec<String>, Vec<String>);
52
+
45
53
  /// Copy the bytes of `s` into a fresh `Vec<u8>`. Single safe entry to
46
54
  /// what would otherwise be an inline `unsafe { rstring.as_slice() }
47
55
  /// .to_vec()` duplicated at every host-↔-guest boundary. The borrow
@@ -53,6 +61,47 @@ fn rstring_to_vec(s: RString) -> Vec<u8> {
53
61
  unsafe { s.as_slice() }.to_vec()
54
62
  }
55
63
 
64
+ /// Frame the Frame 1 preamble from the Service registry's bind paths.
65
+ /// The core envelope's byte layout lives on this side of the boundary,
66
+ /// so the registry stays a registry and never holds a wire image.
67
+ fn frame_preamble(paths: RArray) -> Result<Vec<u8>, MagnusError> {
68
+ Ok(Bindings {
69
+ paths: paths.to_vec()?,
70
+ }
71
+ .encode())
72
+ }
73
+
74
+ /// Frame the Frame 3 snippet table from the registry's entries — one
75
+ /// `[kind, name, body]` triple each, `kind` a Symbol naming the form so
76
+ /// the wire's discriminant byte stays here. An off-ladder kind raises
77
+ /// rather than defaulting, the same fail-closed posture `from_path`
78
+ /// takes on its Symbol options.
79
+ fn frame_snippets(ruby: &Ruby, entries: RArray) -> Result<Vec<u8>, MagnusError> {
80
+ let mut frame = Snippets {
81
+ entries: Vec::with_capacity(entries.len()),
82
+ };
83
+ for index in 0..entries.len() as isize {
84
+ let entry: RArray = entries.entry(index)?;
85
+ let kind: Symbol = entry.entry(0)?;
86
+ frame.entries.push(match kind.name()?.as_ref() {
87
+ "source" => Snippet::Source {
88
+ name: entry.entry(1)?,
89
+ body: entry.entry(2)?,
90
+ },
91
+ "bytecode" => Snippet::Bytecode {
92
+ body: rstring_to_vec(entry.entry(2)?),
93
+ },
94
+ other => {
95
+ return Err(MagnusError::new(
96
+ ruby.exception_arg_error(),
97
+ format!("snippet kind must be :source or :bytecode, got :{other}"),
98
+ ))
99
+ }
100
+ });
101
+ }
102
+ Ok(frame.encode())
103
+ }
104
+
56
105
  // ---------------------------------------------------------------------------
57
106
  // Ruby init
58
107
  // ---------------------------------------------------------------------------
@@ -63,73 +112,49 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
63
112
  // in `runtime/errors.rs` — no intermediate hierarchy is registered.
64
113
 
65
114
  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))?;
115
+ runtime.define_singleton_method("from_path", function!(Runtime::from_path, 7))?;
116
+ runtime.define_method("eval", method!(Runtime::eval, 4))?;
117
+ runtime.define_method("run", method!(Runtime::run, 5))?;
72
118
  runtime.define_method("profile", method!(Runtime::profile, 0))?;
73
119
  // The guest re-enters for a block yield through a frame-scoped
74
120
  // `Kobako::Runtime::GuestYielder` the dispatcher hands the Proc, not a
75
121
  // method on Runtime.
76
122
  bridge::register(runtime)?;
77
123
 
124
+ // Snapshot — the per-invocation result object each entry point returns.
125
+ let snapshot = runtime.define_class("Snapshot", ruby.class_object())?;
126
+ snapshot.define_method("outcome", method!(Snapshot::outcome, 0))?;
127
+ snapshot.define_method("trapped?", method!(Snapshot::trapped, 0))?;
128
+ snapshot.define_method("trap_kind", method!(Snapshot::trap_kind, 0))?;
129
+ snapshot.define_method("trap_message", method!(Snapshot::trap_message, 0))?;
130
+ snapshot.define_method("wall_time", method!(Snapshot::wall_time, 0))?;
131
+ snapshot.define_method("memory_peak", method!(Snapshot::memory_peak, 0))?;
132
+ snapshot.define_method("stdout", method!(Snapshot::stdout, 0))?;
133
+ snapshot.define_method("stdout_truncated?", method!(Snapshot::stdout_truncated, 0))?;
134
+ snapshot.define_method("stderr", method!(Snapshot::stderr, 0))?;
135
+ snapshot.define_method("stderr_truncated?", method!(Snapshot::stderr_truncated, 0))?;
136
+
78
137
  Ok(())
79
138
  }
80
139
 
81
140
  #[derive(TypedData)]
82
- #[magnus(class = "Kobako::Runtime", free_immediately, size, mark)]
141
+ #[magnus(class = "Kobako::Runtime", free_immediately, size)]
83
142
  struct Runtime {
84
143
  // The magnus-free wasmtime driver that runs every invocation; the
85
- // shell only shuttles Ruby values across its boundary.
144
+ // shell only shuttles Ruby values across its boundary. The Runtime
145
+ // holds no per-invocation state — each `#eval` / `#run` takes its
146
+ // dispatch handler as an argument and returns its whole result as a
147
+ // `Snapshot` — so `Driver`'s own `Send + Sync` carries the type with no
148
+ // interior mutability to guard.
86
149
  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)>,
107
- }
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
- }
150
+ // Whether each invocation releases Ruby's GVL for its guest span
151
+ // (`gvl: :release`) or holds it throughout (`gvl: :hold`). Fixed at
152
+ // construction; a `bool` carries no interior mutability, so the type
153
+ // stays `Send + Sync`.
154
+ release_gvl: bool,
124
155
  }
125
156
 
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 {}
157
+ impl DataTypeFunctions for Runtime {}
133
158
 
134
159
  impl Runtime {
135
160
  /// Construct a Runtime from a wasm file path, using the process-wide
@@ -139,20 +164,23 @@ impl Runtime {
139
164
  ///
140
165
  /// `timeout_seconds` is the wall-clock cap in seconds
141
166
  /// (`None` disables); `memory_limit` is the linear-memory cap in
142
- /// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
167
+ /// bytes (`None` disables); `stdout_limit` / `stderr_limit`
143
168
  /// are the per-channel output caps (`None`
144
169
  /// 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.
170
+ /// (`:permissive` / `:hermetic`); `gvl` is the scheduling mode
171
+ /// (`:hold` / `:release`) deciding whether each invocation releases
172
+ /// the GVL for its guest span. All six are validated by the caller
173
+ /// (`Kobako::Sandbox`); this method only refuses non-finite or
174
+ /// non-positive timeouts, off-ladder profiles, and unrecognized gvl
175
+ /// modes as a defence in depth.
149
176
  fn from_path(
150
177
  path: String,
151
178
  timeout_seconds: Option<f64>,
152
179
  memory_limit: Option<usize>,
153
- stdout_limit_bytes: Option<usize>,
154
- stderr_limit_bytes: Option<usize>,
180
+ stdout_limit: Option<usize>,
181
+ stderr_limit: Option<usize>,
155
182
  profile: Symbol,
183
+ gvl: Symbol,
156
184
  ) -> Result<Self, MagnusError> {
157
185
  let ruby = Ruby::get().expect("Ruby thread");
158
186
  let timeout = match timeout_seconds {
@@ -183,66 +211,67 @@ impl Runtime {
183
211
  ));
184
212
  }
185
213
  };
214
+ // Same fail-closed posture as `profile`: an unrecognized mode raises
215
+ // rather than defaulting. `SandboxOptions` is the primary validator;
216
+ // this guards direct `from_path` calls.
217
+ let release_gvl = match gvl.name()?.as_ref() {
218
+ "hold" => false,
219
+ "release" => true,
220
+ other => {
221
+ return Err(MagnusError::new(
222
+ ruby.exception_arg_error(),
223
+ format!("gvl must be :hold or :release, got :{other}"),
224
+ ));
225
+ }
226
+ };
186
227
 
187
228
  let driver = Driver::new(
188
229
  Path::new(&path),
189
- memory_limit,
190
230
  Config {
191
231
  timeout,
192
- stdout_limit_bytes,
193
- stderr_limit_bytes,
232
+ memory_limit,
233
+ stdout_limit,
234
+ stderr_limit,
194
235
  profile,
195
236
  },
196
237
  )
197
238
  .map_err(|e| errors::setup_to_magnus(&ruby, e))?;
198
239
  Ok(Self {
199
240
  driver,
200
- on_dispatch: Cell::new(None),
201
- last_usage: Cell::new(Usage::default()),
202
- last_captures: RefCell::new((Capture::default(), Capture::default())),
241
+ release_gvl,
203
242
  })
204
243
  }
205
244
 
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
245
  // -----------------------------------------------------------------
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.
246
+ // Run-path methods. Each takes the run's dispatch handler as its first
247
+ // argument and returns a `Snapshot` for any completed invocation —
248
+ // success or trap alike. Only a could-not-start fault (a missing export
249
+ // or a fault before the export call) raises a `Kobako::TrapError`
250
+ // directly, since it yields no `Snapshot`.
223
251
  // -----------------------------------------------------------------
224
252
 
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`.
253
+ /// One-shot mruby source execution (`#eval`). Builds the dispatch
254
+ /// handler from `dispatch` (the per-invocation Proc), frames the two
255
+ /// stdin invocation frames from the registry state (`paths`,
256
+ /// `snippets`), hands them and the source to the driver, and returns
257
+ /// the run's `Snapshot`.
232
258
  fn eval(
233
259
  &self,
234
- preamble: RString,
260
+ dispatch: Value,
261
+ paths: RArray,
235
262
  source: RString,
236
- snippets: RString,
237
- ) -> Result<RString, MagnusError> {
263
+ snippets: RArray,
264
+ ) -> Result<Snapshot, MagnusError> {
238
265
  let ruby = Ruby::get().expect("Ruby thread");
239
- let handler = self.build_handler();
240
- let preamble = rstring_to_vec(preamble);
266
+ let handler = build_handler(dispatch);
267
+ let preamble = frame_preamble(paths)?;
241
268
  let source = rstring_to_vec(source);
242
- let snippets = rstring_to_vec(snippets);
243
- let snapshot = self
244
- .driver
245
- .invoke(
269
+ let snippets = frame_snippets(&ruby, snippets)?;
270
+ // Release the GVL around the guest span iff this Sandbox asks for it;
271
+ // the closure touches no Ruby VALUE (the driver is magnus-free, and a
272
+ // guest→host dispatch re-acquires the GVL through the bridge).
273
+ let result = gvl::region(self.release_gvl, || {
274
+ self.driver.invoke(
246
275
  Entry::Eval { source: &source },
247
276
  Frames {
248
277
  preamble: &preamble,
@@ -250,32 +279,40 @@ impl Runtime {
250
279
  },
251
280
  handler,
252
281
  )
253
- .map_err(|e| errors::to_magnus(&ruby, e))?;
254
- self.finish_invocation(&ruby, snapshot)
282
+ });
283
+ let snapshot = result.map_err(|e| errors::to_magnus(&ruby, e))?;
284
+ Ok(Snapshot::from(snapshot))
255
285
  }
256
286
 
257
- /// Execute one entrypoint dispatch (`__kobako_run`) and return the
258
- /// guest's raw outcome bytes.
287
+ /// Execute one entrypoint dispatch (`__kobako_run`) and return its
288
+ /// `Snapshot`.
259
289
  ///
260
290
  /// The two-frame stdin protocol (preamble + snippets; no user source
261
- /// frame — docs/wire-codec.md § Invocation channels) plus the
262
- /// `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.
291
+ /// frame — docs/wire-codec.md § Invocation channels) plus the Run
292
+ /// envelope copied into guest linear memory — `entrypoint` routes it,
293
+ /// `payload` feeds it, and this side frames the two together. Cap
294
+ /// semantics match `#eval`.
265
295
  fn run(
266
296
  &self,
267
- preamble: RString,
268
- snippets: RString,
269
- envelope: RString,
270
- ) -> Result<RString, MagnusError> {
297
+ dispatch: Value,
298
+ paths: RArray,
299
+ snippets: RArray,
300
+ entrypoint: String,
301
+ payload: RString,
302
+ ) -> Result<Snapshot, MagnusError> {
271
303
  let ruby = Ruby::get().expect("Ruby thread");
272
- let handler = self.build_handler();
273
- let preamble = rstring_to_vec(preamble);
274
- let snippets = rstring_to_vec(snippets);
275
- let envelope = rstring_to_vec(envelope);
276
- let snapshot = self
277
- .driver
278
- .invoke(
304
+ let handler = build_handler(dispatch);
305
+ let preamble = frame_preamble(paths)?;
306
+ let snippets = frame_snippets(&ruby, snippets)?;
307
+ let envelope = Run {
308
+ entrypoint,
309
+ payload: rstring_to_vec(payload),
310
+ }
311
+ .encode();
312
+ // Release the GVL around the guest span iff this Sandbox asks for it;
313
+ // see the note in `#eval`.
314
+ let result = gvl::region(self.release_gvl, || {
315
+ self.driver.invoke(
279
316
  Entry::Run {
280
317
  envelope: &envelope,
281
318
  },
@@ -285,103 +322,166 @@ impl Runtime {
285
322
  },
286
323
  handler,
287
324
  )
288
- .map_err(|e| errors::to_magnus(&ruby, e))?;
289
- self.finish_invocation(&ruby, snapshot)
325
+ });
326
+ let snapshot = result.map_err(|e| errors::to_magnus(&ruby, e))?;
327
+ Ok(Snapshot::from(snapshot))
290
328
  }
291
329
 
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> {
330
+ /// Return the isolation profile the driver built, as a Symbol
331
+ /// (`:hermetic` / `:permissive`) — the declaration the Sandbox
332
+ /// compares against the posture its `profile:` option requested.
333
+ fn profile(&self) -> Symbol {
334
+ let ruby = Ruby::get().expect("Ruby thread");
335
+ match self.driver.profile() {
336
+ Profile::Hermetic => ruby.to_symbol("hermetic"),
337
+ Profile::Permissive => ruby.to_symbol("permissive"),
338
+ }
339
+ }
340
+ }
341
+
342
+ /// Build the dispatch handler for one invocation from the per-call `dispatch`
343
+ /// Proc. A `nil` Proc yields no handler. The Proc stays GC-rooted for the
344
+ /// duration of the synchronous `#eval` / `#run` call as a live method
345
+ /// argument on the Ruby stack, so the driver only borrows it (the safety
346
+ /// contract on `kobako_runtime::runtime::Runtime`).
347
+ fn build_handler(dispatch: Value) -> Option<Arc<dyn DispatchHandler>> {
348
+ if dispatch.is_nil() {
349
+ return None;
350
+ }
351
+ Some(
352
+ Arc::new(bridge::RubyDispatchHandler::new(Opaque::from(dispatch)))
353
+ as Arc<dyn DispatchHandler>,
354
+ )
355
+ }
356
+
357
+ /// One invocation's result at the Ruby boundary — the whole `Snapshot` the
358
+ /// driver produced, exposed as `Kobako::Runtime::Snapshot`. Usage and the
359
+ /// two output captures are present on every outcome, so the trap path
360
+ /// carries them just like the value path; the completion is read as either
361
+ /// the outcome bytes (`#outcome`) or a trap (`#trapped?` / `#trap_kind` /
362
+ /// `#trap_message`), and the Sandbox layer maps a trap onto its
363
+ /// `Kobako::TrapError` family.
364
+ #[derive(TypedData)]
365
+ #[magnus(class = "Kobako::Runtime::Snapshot", free_immediately, size)]
366
+ struct Snapshot {
367
+ completion: Completion,
368
+ stdout: Capture,
369
+ stderr: Capture,
370
+ usage: Usage,
371
+ }
372
+
373
+ impl DataTypeFunctions for Snapshot {}
374
+
375
+ impl From<RuntimeSnapshot> for Snapshot {
376
+ fn from(snapshot: RuntimeSnapshot) -> Self {
303
377
  let RuntimeSnapshot {
304
378
  completion,
305
379
  stdout,
306
380
  stderr,
307
381
  usage,
308
382
  } = 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)),
383
+ Self {
384
+ completion,
385
+ stdout,
386
+ stderr,
387
+ usage,
314
388
  }
315
389
  }
390
+ }
316
391
 
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
- })
392
+ impl Snapshot {
393
+ /// One completed run's outcome, already split off the core envelope:
394
+ /// `[kind, payload, panic]`. `kind` names the arm — `:ok`,
395
+ /// `:panic`, `:absent` (nothing written), or `:malformed` (bytes the
396
+ /// envelope cannot frame). `payload` is the invocation's
397
+ /// codec-encoded value, carried only by `:ok`; every other arm
398
+ /// answers empty. `panic` carries the Panic's own fields on `:panic`
399
+ /// and is `nil` otherwise, so the Ruby side maps a failure onto its
400
+ /// error taxonomy without decoding a payload byte.
401
+ ///
402
+ /// A trap answers `:absent` — `#trapped?` is the authoritative
403
+ /// discriminator there and this is never read.
404
+ fn outcome(&self) -> (Symbol, RString, Option<PanicFields>) {
405
+ let ruby = Ruby::get().expect("Ruby thread");
406
+ let empty = || ruby.str_from_slice(&[]);
407
+ let Completion::Outcome(bytes) = &self.completion else {
408
+ return (ruby.to_symbol("absent"), empty(), None);
409
+ };
410
+ match Outcome::decode(bytes) {
411
+ Ok(Outcome::Ok(value)) => (ruby.to_symbol("ok"), ruby.str_from_slice(&value), None),
412
+ Ok(Outcome::Panic(panic)) => (
413
+ ruby.to_symbol("panic"),
414
+ empty(),
415
+ Some((
416
+ panic.origin.name().to_owned(),
417
+ panic.error.name,
418
+ panic.error.message,
419
+ panic.error.backtrace,
420
+ panic.available,
421
+ )),
422
+ ),
423
+ Err(_) if bytes.is_empty() => (ruby.to_symbol("absent"), empty(), None),
424
+ Err(_) => (ruby.to_symbol("malformed"), empty(), None),
425
+ }
326
426
  }
327
427
 
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> {
428
+ /// `true` iff the invocation completed via an engine trap.
429
+ fn trapped(&self) -> bool {
430
+ matches!(self.completion, Completion::Trap(_))
431
+ }
432
+
433
+ /// The trap's neutral kind as a Symbol (`:timeout` / `:memory_limit` /
434
+ /// `:trap`), or `nil` on a completed run. The Sandbox maps this onto the
435
+ /// named `Kobako::TrapError` subclass.
436
+ fn trap_kind(&self) -> Option<Symbol> {
347
437
  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)
438
+ match &self.completion {
439
+ Completion::Trap(Trap::Timeout(_)) => Some(ruby.to_symbol("timeout")),
440
+ Completion::Trap(Trap::MemoryLimit(_)) => Some(ruby.to_symbol("memory_limit")),
441
+ // A cap this frontend has no named subclass for is still a
442
+ // trap; the base class is what the taxonomy owes it.
443
+ Completion::Trap(_) => Some(ruby.to_symbol("trap")),
444
+ Completion::Outcome(_) => None,
445
+ }
353
446
  }
354
447
 
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> {
448
+ /// The trap's message, or `nil` on a completed run.
449
+ fn trap_message(&self) -> Option<String> {
450
+ match &self.completion {
451
+ Completion::Trap(trap) => Some(trap.to_string()),
452
+ Completion::Outcome(_) => None,
453
+ }
454
+ }
455
+
456
+ /// Wall-clock seconds the guest export call spent inside wasmtime.
457
+ fn wall_time(&self) -> f64 {
458
+ self.usage.wall_time
459
+ }
460
+
461
+ /// High-water `memory.grow` delta in bytes past the entry-time baseline.
462
+ fn memory_peak(&self) -> usize {
463
+ self.usage.memory_peak
464
+ }
465
+
466
+ /// Bytes captured on the guest's stdout channel, clipped to the cap.
467
+ fn stdout(&self) -> RString {
366
468
  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)
469
+ ruby.str_from_slice(&self.stdout.bytes)
375
470
  }
376
471
 
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 {
472
+ /// `true` iff the stdout channel reached its cap during this run.
473
+ fn stdout_truncated(&self) -> bool {
474
+ self.stdout.truncated
475
+ }
476
+
477
+ /// Bytes captured on the guest's stderr channel, clipped to the cap.
478
+ fn stderr(&self) -> RString {
381
479
  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
- }
480
+ ruby.str_from_slice(&self.stderr.bytes)
481
+ }
482
+
483
+ /// `true` iff the stderr channel reached its cap during this run.
484
+ fn stderr_truncated(&self) -> bool {
485
+ self.stderr.truncated
386
486
  }
387
487
  }