kobako 0.12.1 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +11 -0
  4. data/Cargo.lock +15 -2
  5. data/Cargo.toml +6 -2
  6. data/README.md +1 -1
  7. data/crates/kobako-runtime/CHANGELOG.md +8 -0
  8. data/crates/kobako-runtime/Cargo.toml +23 -0
  9. data/crates/kobako-runtime/README.md +34 -0
  10. data/crates/kobako-runtime/src/dispatch.rs +22 -0
  11. data/crates/kobako-runtime/src/error.rs +64 -0
  12. data/crates/kobako-runtime/src/lib.rs +16 -0
  13. data/crates/kobako-runtime/src/runtime.rs +50 -0
  14. data/crates/kobako-runtime/src/snapshot.rs +46 -0
  15. data/crates/kobako-runtime/src/yielder.rs +22 -0
  16. data/crates/kobako-wasmtime/CHANGELOG.md +8 -0
  17. data/crates/kobako-wasmtime/Cargo.toml +62 -0
  18. data/crates/kobako-wasmtime/README.md +32 -0
  19. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/ambient.rs +3 -3
  20. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/cache.rs +30 -41
  21. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/capture.rs +2 -2
  22. data/crates/kobako-wasmtime/src/config.rs +25 -0
  23. data/crates/kobako-wasmtime/src/dispatch.rs +110 -0
  24. data/crates/kobako-wasmtime/src/driver.rs +285 -0
  25. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/exports.rs +5 -6
  26. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/frames.rs +70 -82
  27. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/guest_mem.rs +38 -15
  28. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/instance_pre.rs +13 -21
  29. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/invocation.rs +54 -49
  30. data/crates/kobako-wasmtime/src/lib.rs +47 -0
  31. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/trap.rs +29 -35
  32. data/data/kobako.wasm +0 -0
  33. data/ext/kobako/Cargo.toml +9 -32
  34. data/ext/kobako/src/runtime/bridge.rs +150 -0
  35. data/ext/kobako/src/runtime/errors.rs +45 -13
  36. data/ext/kobako/src/runtime.rs +156 -406
  37. data/ext/kobako/src/snapshot.rs +27 -62
  38. data/lib/kobako/catalog/handles.rb +3 -3
  39. data/lib/kobako/catalog/namespaces.rb +4 -0
  40. data/lib/kobako/catalog/snippets.rb +4 -0
  41. data/lib/kobako/codec/encoder.rb +5 -1
  42. data/lib/kobako/codec/factory.rb +41 -13
  43. data/lib/kobako/codec/handle_walk.rb +4 -0
  44. data/lib/kobako/errors.rb +18 -16
  45. data/lib/kobako/sandbox.rb +20 -18
  46. data/lib/kobako/sandbox_options.rb +25 -9
  47. data/lib/kobako/snapshot.rb +7 -13
  48. data/lib/kobako/transport/dispatcher.rb +2 -2
  49. data/lib/kobako/transport/response.rb +14 -14
  50. data/lib/kobako/transport/run.rb +2 -6
  51. data/lib/kobako/transport/yield.rb +1 -1
  52. data/lib/kobako/transport/yielder.rb +2 -2
  53. data/lib/kobako/version.rb +1 -1
  54. data/release-please-config.json +48 -3
  55. data/sig/kobako/codec/factory.rbs +3 -0
  56. data/sig/kobako/errors.rbs +7 -14
  57. data/sig/kobako/runtime.rbs +8 -3
  58. data/sig/kobako/sandbox.rbs +2 -2
  59. data/sig/kobako/sandbox_options.rbs +4 -2
  60. data/sig/kobako/snapshot.rbs +0 -3
  61. data/sig/kobako/transport/dispatcher.rbs +1 -1
  62. data/sig/kobako/transport/run.rbs +2 -2
  63. data/sig/kobako/transport/yielder.rbs +2 -2
  64. data/sig/kobako/transport.rbs +8 -0
  65. metadata +27 -12
  66. data/ext/kobako/src/runtime/config.rs +0 -25
  67. data/ext/kobako/src/runtime/dispatch.rs +0 -211
@@ -1,46 +1,49 @@
1
1
  //! Per-invocation host state — the materialised
2
2
  //! [SPEC.md Single-Invocation Slot] (one `Invocation` per OS thread
3
- //! for the lifetime of one `Runtime::eval` / `Runtime::run` call).
3
+ //! for the lifetime of one `Driver` invoke call).
4
4
  //!
5
5
  //! Owned as the data of each per-invocation `wasmtime::Store`
6
6
  //! and threaded through every host import —
7
- //! the `__kobako_dispatch` dispatcher reads the bound dispatch Proc,
8
- //! while the run-path methods on `crate::runtime::Runtime` install the
9
- //! invocation's WASI context + pipes at Store creation.
7
+ //! the `__kobako_dispatch` dispatcher reads the bound dispatch handler,
8
+ //! while `Driver::invoke` installs the invocation's WASI
9
+ //! context + pipes (via `frames::install_wasi_frames`) before the guest
10
+ //! export call.
10
11
  //!
11
12
  //! The slot also carries the per-invocation wall-clock deadline
12
13
  //! and the per-invocation linear-memory
13
14
  //! delta cap `MemoryLimiter`. Both are
14
15
  //! read from the wasmtime `epoch_deadline_callback` / `ResourceLimiter`
15
- //! callbacks installed in `crate::runtime::Runtime::new_store`. The
16
+ //! callbacks installed in `Driver::new_store`. The
16
17
  //! memory cap measures only the `memory.grow` delta past the linear-
17
18
  //! memory size captured at invocation entry — the image's initial
18
19
  //! allocation is outside the budget.
19
20
  //!
20
- //! [SPEC.md Single-Invocation Slot]: ../../../../SPEC.md
21
+ //! [SPEC.md Single-Invocation Slot]: ../../../SPEC.md
21
22
 
23
+ use std::sync::Arc;
22
24
  use std::time::{Duration, Instant};
23
25
 
24
- use magnus::{value::Opaque, Value};
25
26
  use wasmtime::ResourceLimiter;
26
27
  use wasmtime_wasi::p1::WasiP1Ctx;
27
28
  use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
28
29
 
30
+ use kobako_runtime::dispatch::DispatchHandler;
31
+
29
32
  /// Per-invocation host state — the data half of the Single-Invocation
30
33
  /// Slot. Threaded through every host import callback.
31
34
  ///
32
35
  /// All field access is mediated by methods on this type — the WASI ctx
33
36
  /// is rebuilt fresh before each invocation via
34
- /// `Invocation::install_wasi`, the Ruby dispatch Proc is set once via
37
+ /// `Invocation::install_wasi`, the dispatch handler is set once via
35
38
  /// `Invocation::bind_on_dispatch`, and captured stdout/stderr bytes
36
39
  /// are read after the invocation via `Invocation::stdout_bytes` /
37
40
  /// `Invocation::stderr_bytes`. The fields are private so the mutation
38
41
  /// surface stays narrow.
39
- pub(super) struct Invocation {
42
+ pub(crate) struct Invocation {
40
43
  wasi: Option<WasiP1Ctx>,
41
44
  stdout_pipe: Option<MemoryOutputPipe>,
42
45
  stderr_pipe: Option<MemoryOutputPipe>,
43
- on_dispatch: Option<Opaque<Value>>,
46
+ on_dispatch: Option<Arc<dyn DispatchHandler>>,
44
47
  deadline: Option<Instant>,
45
48
  limiter: MemoryLimiter,
46
49
  wall_entry: Option<Instant>,
@@ -52,7 +55,7 @@ impl Invocation {
52
55
  /// `Sandbox#memory_limit` cap in bytes (or `None` to disable the cap);
53
56
  /// it is read from the wasmtime `ResourceLimiter` callback every
54
57
  /// time the guest grows linear memory.
55
- pub(super) fn new(memory_limit: Option<usize>) -> Self {
58
+ pub(crate) fn new(memory_limit: Option<usize>) -> Self {
56
59
  Self {
57
60
  wasi: None,
58
61
  stdout_pipe: None,
@@ -66,10 +69,10 @@ impl Invocation {
66
69
  }
67
70
 
68
71
  /// Install a freshly-built WASI context plus the matching stdout/stderr
69
- /// pipe clones. Called from `crate::runtime::Runtime::eval` /
70
- /// `crate::runtime::Runtime::run` at the top of every guest
72
+ /// pipe clones. Called from `frames::install_wasi_frames`, which
73
+ /// `Driver::invoke` runs at the top of every guest
71
74
  /// invocation.
72
- pub(super) fn install_wasi(
75
+ pub(crate) fn install_wasi(
73
76
  &mut self,
74
77
  wasi: WasiP1Ctx,
75
78
  stdout: MemoryOutputPipe,
@@ -80,16 +83,16 @@ impl Invocation {
80
83
  self.stderr_pipe = Some(stderr);
81
84
  }
82
85
 
83
- /// Register the Ruby-side dispatch `Proc`. From this point on, every
84
- /// `__kobako_dispatch` host import invocation calls the Proc with the
85
- /// request bytes and expects encoded Response bytes back.
86
- pub(super) fn bind_on_dispatch(&mut self, proc_value: Opaque<Value>) {
87
- self.on_dispatch = Some(proc_value);
86
+ /// Bind the dispatch handler for this invocation. From this point on,
87
+ /// every `__kobako_dispatch` host import invocation hands the handler
88
+ /// the request bytes and expects encoded Response bytes back.
89
+ pub(crate) fn bind_on_dispatch(&mut self, handler: Arc<dyn DispatchHandler>) {
90
+ self.on_dispatch = Some(handler);
88
91
  }
89
92
 
90
93
  /// Snapshot the bytes captured on guest fd 1 during the most recent
91
94
  /// run. Empty vec before any run.
92
- pub(super) fn stdout_bytes(&self) -> Vec<u8> {
95
+ pub(crate) fn stdout_bytes(&self) -> Vec<u8> {
93
96
  self.stdout_pipe
94
97
  .as_ref()
95
98
  .map(|p| p.contents().to_vec())
@@ -98,53 +101,55 @@ impl Invocation {
98
101
 
99
102
  /// Snapshot the bytes captured on guest fd 2 during the most recent
100
103
  /// run. Empty vec before any run.
101
- pub(super) fn stderr_bytes(&self) -> Vec<u8> {
104
+ pub(crate) fn stderr_bytes(&self) -> Vec<u8> {
102
105
  self.stderr_pipe
103
106
  .as_ref()
104
107
  .map(|p| p.contents().to_vec())
105
108
  .unwrap_or_default()
106
109
  }
107
110
 
108
- /// Return the bound dispatch Proc handle. `Opaque<Value>` is `Copy`,
109
- /// so the handle is returned by value rather than by reference. None
110
- /// means no Proc has been bound yet via
111
+ /// Return a clone of the bound dispatch handler (an `Arc`, so the clone
112
+ /// is a cheap refcount bump). Cloning releases the borrow on the
113
+ /// `Caller` so the dispatcher can re-borrow it to write the response.
114
+ /// None means no handler has been bound yet via
111
115
  /// `Invocation::bind_on_dispatch`.
112
- pub(super) fn on_dispatch(&self) -> Option<Opaque<Value>> {
113
- self.on_dispatch
116
+ pub(crate) fn on_dispatch(&self) -> Option<Arc<dyn DispatchHandler>> {
117
+ self.on_dispatch.clone()
114
118
  }
115
119
 
116
120
  /// Mutable handle to the live WASI context. Panics if no context has
117
121
  /// been installed yet — every call site is downstream of
118
- /// `Invocation::install_wasi` running at the top of
119
- /// `Runtime::eval` / `Runtime::run`, so reaching this branch with
120
- /// `None` signals a host-side wiring bug.
121
- pub(super) fn wasi_mut(&mut self) -> &mut WasiP1Ctx {
122
+ /// `Invocation::install_wasi` running at the top of every `Driver`
123
+ /// invoke, so reaching this branch with `None` signals a host-side
124
+ /// wiring bug.
125
+ pub(crate) fn wasi_mut(&mut self) -> &mut WasiP1Ctx {
122
126
  self.wasi.as_mut().expect(
123
- "WASI context not initialised — call Runtime#eval / Runtime#run before any WASI use",
127
+ "WASI context not initialised — the driver must install frames before any WASI use",
124
128
  )
125
129
  }
126
130
 
127
131
  /// Replace the per-run wall-clock deadline. `Some(at)` makes the
128
132
  /// epoch-deadline callback trap once `Instant::now() >= at`; `None`
129
- /// disables the cap. Called at the top of every `#run`.
130
- pub(super) fn set_deadline(&mut self, deadline: Option<Instant>) {
133
+ /// disables the cap. Called from `Driver::prime_caps` at the top of
134
+ /// every invocation (`#eval` and `#run`).
135
+ pub(crate) fn set_deadline(&mut self, deadline: Option<Instant>) {
131
136
  self.deadline = deadline;
132
137
  }
133
138
 
134
139
  /// Return the current per-run deadline. Read from the epoch-deadline
135
- /// callback installed by `crate::runtime::Runtime::new_store`.
136
- pub(super) fn deadline(&self) -> Option<Instant> {
140
+ /// callback installed by `Driver::new_store`.
141
+ pub(crate) fn deadline(&self) -> Option<Instant> {
137
142
  self.deadline
138
143
  }
139
144
 
140
145
  /// Mutable handle to the embedded `MemoryLimiter`. Required by
141
146
  /// the wasmtime `ResourceLimiter` callback wiring in
142
- /// `crate::runtime::Runtime::new_store`
147
+ /// `Driver::new_store`
143
148
  /// (`store.limiter(|state| state.limiter_mut())`); kept private to
144
149
  /// the wasm submodule so the only public surface for arming the
145
150
  /// cap goes through `Invocation::arm_memory_cap` /
146
151
  /// `Invocation::disarm_memory_cap`.
147
- pub(super) fn limiter_mut(&mut self) -> &mut MemoryLimiter {
152
+ pub(crate) fn limiter_mut(&mut self) -> &mut MemoryLimiter {
148
153
  &mut self.limiter
149
154
  }
150
155
 
@@ -157,13 +162,13 @@ impl Invocation {
157
162
  /// call to the corresponding `__kobako_*` export so post-run host
158
163
  /// bookkeeping (e.g. fetching the OUTCOME_BUFFER) is not
159
164
  /// attributed to the user script.
160
- pub(super) fn arm_memory_cap(&mut self, baseline: usize) {
165
+ pub(crate) fn arm_memory_cap(&mut self, baseline: usize) {
161
166
  self.limiter.activate(baseline);
162
167
  }
163
168
 
164
169
  /// Disarm the memory cap. See
165
170
  /// `Invocation::arm_memory_cap`.
166
- pub(super) fn disarm_memory_cap(&mut self) {
171
+ pub(crate) fn disarm_memory_cap(&mut self) {
167
172
  self.limiter.deactivate();
168
173
  }
169
174
 
@@ -173,7 +178,7 @@ impl Invocation {
173
178
  /// bracket matches the `timeout` deadline accounting and
174
179
  /// excludes post-run host bookkeeping such as `OUTCOME_BUFFER`
175
180
  /// decoding.
176
- pub(super) fn start_wall_clock(&mut self) {
181
+ pub(crate) fn start_wall_clock(&mut self) {
177
182
  self.wall_entry = Some(Instant::now());
178
183
  }
179
184
 
@@ -182,7 +187,7 @@ impl Invocation {
182
187
  /// stop with no matching start (e.g. if the guest export call
183
188
  /// never executed because of a host-side allocation failure)
184
189
  /// leaves the previously-recorded value untouched.
185
- pub(super) fn stop_wall_clock(&mut self) {
190
+ pub(crate) fn stop_wall_clock(&mut self) {
186
191
  if let Some(entry) = self.wall_entry.take() {
187
192
  self.wall_time = entry.elapsed();
188
193
  }
@@ -191,7 +196,7 @@ impl Invocation {
191
196
  /// Return the wall-clock duration the most recent invocation
192
197
  /// spent inside the guest export call.
193
198
  /// Zero before the first invocation.
194
- pub(super) fn wall_time(&self) -> Duration {
199
+ pub(crate) fn wall_time(&self) -> Duration {
195
200
  self.wall_time
196
201
  }
197
202
 
@@ -199,7 +204,7 @@ impl Invocation {
199
204
  /// water mark of the per-invocation `memory.grow` delta past the
200
205
  /// linear-memory size captured at invocation entry. Zero before
201
206
  /// the first invocation.
202
- pub(super) fn memory_peak(&self) -> usize {
207
+ pub(crate) fn memory_peak(&self) -> usize {
203
208
  self.limiter.peak()
204
209
  }
205
210
  }
@@ -216,16 +221,16 @@ impl Invocation {
216
221
  /// `cap_active` gates whether the cap is enforced — wasmtime's
217
222
  /// `ResourceLimiter` also fires for the module's declared initial
218
223
  /// allocation at instantiation time, but the cap stays dormant until
219
- /// `MemoryLimiter::activate` flips the flag for one
220
- /// `Runtime::eval` / `Runtime::run` call. When `cap_active` is
221
- /// `false`, the limiter always allows growth.
224
+ /// `MemoryLimiter::activate` flips the flag for one `Driver` invoke
225
+ /// call. When `cap_active` is `false`, the limiter always allows
226
+ /// growth.
222
227
  ///
223
228
  /// When `memory.grow` would push the per-invocation delta past
224
229
  /// `max_memory`, the limiter returns `MemoryLimitTrap` from
225
230
  /// `memory_growing`; wasmtime turns that into the trap surfaced to the
226
231
  /// host as a guest invocation failure.
227
232
  #[derive(Debug, Clone, Copy)]
228
- pub(super) struct MemoryLimiter {
233
+ pub(crate) struct MemoryLimiter {
229
234
  max_memory: Option<usize>,
230
235
  baseline: usize,
231
236
  cap_active: bool,
@@ -274,7 +279,7 @@ impl MemoryLimiter {
274
279
  /// rejected `desired` values that trip the memory
275
280
  /// cap never update the peak, so the reported value never exceeds
276
281
  /// `memory_limit`.
277
- pub(super) fn peak(&self) -> usize {
282
+ pub(crate) fn peak(&self) -> usize {
278
283
  self.peak
279
284
  }
280
285
  }
@@ -329,7 +334,7 @@ impl MemoryLimitTrap {
329
334
  /// by the sibling-module `classify_trap` unit tests to materialise
330
335
  /// a representative error for downcast routing.
331
336
  #[cfg(test)]
332
- pub(super) fn new(desired: usize, limit: usize) -> Self {
337
+ pub(crate) fn new(desired: usize, limit: usize) -> Self {
333
338
  Self { desired, limit }
334
339
  }
335
340
  }
@@ -0,0 +1,47 @@
1
+ //! kobako-wasmtime — the wasmtime implementation of the kobako runtime
2
+ //! contract.
3
+ //!
4
+ //! `Driver` implements `kobako_runtime::runtime::Runtime` over wasmtime:
5
+ //! every invocation instantiates a fresh instance from a pre-linked
6
+ //! template and discards the whole Store afterwards — the
7
+ //! per-invocation instance discipline (ABI v2). Everything engine-bound
8
+ //! lives behind the contract surface, so a frontend shell (the Ruby
9
+ //! ext's `Kobako::Runtime`) sees no wasmtime type.
10
+ //!
11
+ //! Module layout (one responsibility per file):
12
+ //!
13
+ //! * `driver` — `Driver` + `impl kobako_runtime::runtime::Runtime`
14
+ //! (the run mechanics).
15
+ //! * `cache` — process-wide Engine + per-path Module cache and the
16
+ //! process-singleton epoch ticker thread.
17
+ //! * `config` — per-Driver caps (timeout / stdout / stderr limits).
18
+ //! * `exports` — per-invocation `__kobako_eval` / `_run` /
19
+ //! `_take_outcome` / `_alloc` / `memory` handles.
20
+ //! * `instance_pre` — host-import Linker wiring + per-path
21
+ //! `InstancePre` cache.
22
+ //! * `invocation` — Invocation (per-Store context), the
23
+ //! `MemoryLimiter` memory cap, and the trap marker types
24
+ //! (`TimeoutTrap` / `MemoryLimitTrap`).
25
+ //! * `dispatch` — `__kobako_dispatch` host-import dispatch helpers.
26
+ //! * `frames` — stdin frame stream + WASI context assembly, `#run`
27
+ //! envelope write, OUTCOME_BUFFER readout.
28
+ //! * `guest_mem` — Caller-based guest linear-memory alloc / write / read.
29
+ //! * `capture` — stdout / stderr pipe sizing + clip helpers.
30
+ //! * `ambient` — frozen WASI clocks + constant RNG (ambient denial).
31
+ //! * `trap` — wasmtime-error → neutral `Trap` classification.
32
+
33
+ mod ambient;
34
+ mod cache;
35
+ mod capture;
36
+ mod config;
37
+ mod dispatch;
38
+ mod driver;
39
+ mod exports;
40
+ mod frames;
41
+ mod guest_mem;
42
+ mod instance_pre;
43
+ mod invocation;
44
+ mod trap;
45
+
46
+ pub use config::Config;
47
+ pub use driver::Driver;
@@ -6,25 +6,24 @@
6
6
  //! `TimeoutTrap`. The classification is a pure function over the error's
7
7
  //! downcast chain so it can be exercised from `cargo test` without the
8
8
  //! magnus surface; the trap marker types themselves live in
9
- //! `super::invocation` (where the limiter / callback construct them).
9
+ //! `crate::invocation` (where the limiter / callback construct them).
10
10
 
11
11
  use std::time::Instant;
12
12
 
13
- use magnus::{Error as MagnusError, Ruby};
14
13
  use wasmtime::{StoreContextMut, UpdateDeadline};
15
14
 
16
- use super::errors::{memory_limit_err, setup_err, timeout_err, trap_err};
17
- use super::invocation::{Invocation, MemoryLimitTrap, TimeoutTrap};
15
+ use crate::invocation::{Invocation, MemoryLimitTrap, TimeoutTrap};
16
+ use kobako_runtime::error::{SetupError, Trap};
18
17
 
19
18
  /// Epoch-deadline callback installed on every Store. Read the per-run
20
19
  /// wall-clock deadline from `Invocation` and trap with
21
20
  /// `TimeoutTrap` once the deadline has passed; otherwise extend the
22
21
  /// next check by one tick of the process-wide epoch ticker. When the
23
- /// deadline is `None` the callback should not fire under normal
24
- /// `Runtime::eval` / `Runtime::run` flow because
22
+ /// deadline is `None` the callback should not fire under the normal
23
+ /// `Driver` invoke flow because
25
24
  /// `set_epoch_deadline(u64::MAX)` is used; returning a long extension
26
25
  /// keeps the callback inert as a defence in depth.
27
- pub(super) fn epoch_deadline_callback(
26
+ pub(crate) fn epoch_deadline_callback(
28
27
  ctx: StoreContextMut<'_, Invocation>,
29
28
  ) -> wasmtime::Result<UpdateDeadline> {
30
29
  match ctx.data().deadline() {
@@ -63,39 +62,34 @@ fn classify_trap(err: &wasmtime::Error) -> TrapClass {
63
62
  }
64
63
  }
65
64
 
66
- /// Map a wasmtime call error to the right top-level `Kobako::*` Ruby
67
- /// exception class. The ABI export symbol (`__kobako_eval` /
68
- /// `__kobako_run`) is deliberately omitted from the message — the
69
- /// Sandbox layer attaches the user-facing verb (`Sandbox#eval` /
70
- /// `Sandbox#run`) so the message reads in caller vocabulary rather
71
- /// than ABI vocabulary.
65
+ /// Classify a wasmtime call error into a neutral `Trap`. The ABI export
66
+ /// symbol (`__kobako_eval` / `__kobako_run`) is deliberately omitted from
67
+ /// the message the Sandbox layer attaches the user-facing verb
68
+ /// (`Sandbox#eval` / `Sandbox#run`) so the message reads in caller
69
+ /// vocabulary rather than ABI vocabulary.
72
70
  ///
73
71
  /// For the configured-cap paths (`TrapClass::Timeout` /
74
72
  /// `TrapClass::MemoryLimit`) the trap's own `std::fmt::Display`
75
73
  /// carries the user-facing reason (`"wall-clock deadline exceeded"`,
76
74
  /// `"linear memory growth exceeded memory_limit: ..."`). The wasmtime
77
- /// outer wrapper at `format!("{}", err)` would otherwise surface only
75
+ /// outer wrapper at `format!("{err}")` would otherwise surface only
78
76
  /// the `"error while executing at wasm backtrace: ..."` framing, which
79
77
  /// is operator noise on a cap trap. For `TrapClass::Other` the framing
80
78
  /// is kept but the chain's root cause is appended (see
81
79
  /// `other_trap_message`) so the real trap reason survives.
82
- pub(super) fn call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
80
+ pub(crate) fn trap_from(err: wasmtime::Error) -> Trap {
83
81
  match classify_trap(&err) {
84
- TrapClass::Timeout => {
85
- let msg = err
86
- .downcast_ref::<TimeoutTrap>()
82
+ TrapClass::Timeout => Trap::Timeout(
83
+ err.downcast_ref::<TimeoutTrap>()
87
84
  .map(|t| t.to_string())
88
- .unwrap_or_else(|| format!("{}", err));
89
- timeout_err(ruby, msg)
90
- }
91
- TrapClass::MemoryLimit => {
92
- let msg = err
93
- .downcast_ref::<MemoryLimitTrap>()
85
+ .unwrap_or_else(|| format!("{err}")),
86
+ ),
87
+ TrapClass::MemoryLimit => Trap::MemoryLimit(
88
+ err.downcast_ref::<MemoryLimitTrap>()
94
89
  .map(|t| t.to_string())
95
- .unwrap_or_else(|| format!("{}", err));
96
- memory_limit_err(ruby, msg)
97
- }
98
- TrapClass::Other => trap_err(ruby, other_trap_message(&err)),
90
+ .unwrap_or_else(|| format!("{err}")),
91
+ ),
92
+ TrapClass::Other => Trap::Other(other_trap_message(&err)),
99
93
  }
100
94
  }
101
95
 
@@ -116,21 +110,21 @@ fn other_trap_message(err: &wasmtime::Error) -> String {
116
110
  }
117
111
  }
118
112
 
119
- /// Map an instantiation error to `Kobako::SetupError`. Instantiation runs
120
- /// during `from_path` construction, before any invocation — every such
121
- /// failure is a construction setup fault, not a
113
+ /// Classify an instantiation error as a runtime-dead `SetupError`.
114
+ /// Instantiation runs during `from_path` construction, before any
115
+ /// invocation — every such failure is a construction setup fault, not a
122
116
  /// per-invocation cap outcome. The memory cap is dormant during
123
117
  /// instantiation (see `Invocation::arm_memory_cap` /
124
118
  /// `Invocation::disarm_memory_cap`) and the epoch deadline is not yet
125
- /// armed, so the `call_err` trap-class split does not apply here.
126
- pub(super) fn instantiate_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
127
- setup_err(ruby, format!("instantiate: {}", err))
119
+ /// armed, so the `trap_from` trap-class split does not apply here.
120
+ pub(crate) fn instantiate_err(err: wasmtime::Error) -> SetupError {
121
+ SetupError::Dead(format!("instantiate: {err}"))
128
122
  }
129
123
 
130
124
  #[cfg(test)]
131
125
  mod tests {
132
126
  use super::{classify_trap, other_trap_message, TrapClass};
133
- use crate::runtime::invocation::{MemoryLimitTrap, TimeoutTrap};
127
+ use crate::invocation::{MemoryLimitTrap, TimeoutTrap};
134
128
 
135
129
  #[test]
136
130
  fn classify_trap_routes_timeout_trap_to_timeout() {
data/data/kobako.wasm CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.12.1"
3
+ version = "0.12.2"
4
4
  edition = "2021"
5
5
  authors = ["Aotokitsuruya <contact@aotoki.me>"]
6
6
  license = "Apache-2.0"
@@ -11,34 +11,11 @@ crate-type = ["cdylib"]
11
11
 
12
12
  [dependencies]
13
13
  magnus = { version = "0.8.2" }
14
- # wasmtime host-side embedder for kobako.wasm. We disable default-features
15
- # and opt back in only what kobako needs: a Cranelift-backed runtime that can
16
- # compile a pre-built wasm32-wasip1 module on the host triple, plus the `wat`
17
- # feature so test fixtures can be expressed as text.
18
- # `cache` / `parallel-compilation` / `pooling` / `component-model` / `async`
19
- # are intentionally off — kobako runs short-lived synchronous sandboxes.
20
- wasmtime = { version = "45.0.0", default-features = false, features = [
21
- "cranelift",
22
- "runtime",
23
- "gc",
24
- "gc-drc",
25
- "addr2line",
26
- "demangle",
27
- "wat",
28
- ] }
29
- # wasmtime-wasi provides WASI preview1 support for routing guest stdout/stderr
30
- # into in-memory buffers. The `p1` feature enables the
31
- # WasiCtxBuilder + preview1 adapter which wires fd 1/2 to pipes. We omit
32
- # `p2` (component-model) and `p0`/`p3` (async) because kobako runs
33
- # synchronous sandboxes only.
34
- wasmtime-wasi = { version = "45.0.0", default-features = false, features = ["p1"] }
35
- # sha2 keys the on-disk compiled-module cache by Guest Binary content
36
- # (see runtime/cache.rs); a collision would load the wrong artifact, so
37
- # the hash must be cryptographic.
38
- sha2 = "0.11"
39
-
40
- # libc supplies geteuid for the cache-directory ownership check gating
41
- # the unsafe artifact deserialize (see runtime/cache.rs); std exposes a
42
- # file's owner but not the process's effective uid.
43
- [target.'cfg(unix)'.dependencies]
44
- libc = "0.2"
14
+ # The engine-neutral host runtime contract plus its wasmtime driver —
15
+ # the ext is a thin magnus shim over them. Pure path dependencies: both
16
+ # crates ship inside the gem, so installs never resolve them against
17
+ # crates.io and the gem release cadence stays decoupled from theirs.
18
+ # wasmtime itself enters only through kobako-wasmtime; no engine type
19
+ # reaches this crate.
20
+ kobako-runtime = { path = "../../crates/kobako-runtime" }
21
+ kobako-wasmtime = { path = "../../crates/kobako-wasmtime" }
@@ -0,0 +1,150 @@
1
+ //! The magnus bridge for the guest→host dispatch seam.
2
+ //!
3
+ //! The Ruby-Proc `DispatchHandler` and the frame-scoped `GuestYielder`
4
+ //! handle the Proc re-enters the guest through — the one place the
5
+ //! dispatch seam touches `magnus`. The wasm-side dispatch path
6
+ //! (`kobako_wasmtime`'s dispatch module) sees only the contract traits.
7
+
8
+ use core::cell::Cell;
9
+ use core::ptr::NonNull;
10
+
11
+ use magnus::value::{Opaque, ReprValue};
12
+ use magnus::{method, prelude::*, Error as MagnusError, RClass, RString, Ruby, Value};
13
+
14
+ use kobako_runtime::dispatch::DispatchHandler;
15
+ use kobako_runtime::yielder::Yielder;
16
+
17
+ /// Register the `Kobako::Runtime::GuestYielder` Ruby class. Called from
18
+ /// `crate::runtime::init` after `Kobako::Runtime` is defined so the
19
+ /// `#[magnus::wrap]` class name resolves before any object is wrapped.
20
+ pub(super) fn register(runtime_class: RClass) -> Result<(), MagnusError> {
21
+ let ruby = Ruby::get().expect("Ruby thread");
22
+ let class = runtime_class.define_class("GuestYielder", ruby.class_object())?;
23
+ class.define_method("call", method!(GuestYielder::call, 1))?;
24
+ Ok(())
25
+ }
26
+
27
+ /// Frame-scoped Ruby handle that lets the dispatch `Proc` re-enter the
28
+ /// guest to run a yielded block. It wraps the active `&mut dyn Yielder`
29
+ /// for exactly one `__kobako_dispatch` frame: the bridge builds one, hands
30
+ /// it to the `Proc` as the second argument, and `invalidate`s it the
31
+ /// instant the `Proc` returns. A guest block stashed and called after that
32
+ /// frame normally raises `LocalJumpError` at the Ruby
33
+ /// `Transport::Yielder` net — invalidated in the dispatcher's `ensure`,
34
+ /// which fires before this handle is reached. This inner invalidation is
35
+ /// the backstop behind that outer net: it keeps `call`'s `unsafe`
36
+ /// `NonNull` deref from touching freed stack should the outer net ever be
37
+ /// bypassed, so neither net is redundant.
38
+ ///
39
+ /// This is the single, explicit, frame-scoped FFI pointer the host↔guest
40
+ /// re-entry still costs: `magnus`' `funcall` sits between two Rust frames,
41
+ /// so the typed `&mut dyn Yielder` cannot cross it and is erased to a raw
42
+ /// pointer here. Unlike the dispatch `Proc`, this handle holds **no Ruby
43
+ /// `Value`**, so GC has nothing to trace through it — it needs no `mark`.
44
+ #[magnus::wrap(class = "Kobako::Runtime::GuestYielder", free_immediately, size)]
45
+ struct GuestYielder {
46
+ yielder: Cell<Option<NonNull<dyn Yielder>>>,
47
+ }
48
+
49
+ // SAFETY: magnus requires `Send + Sync` on wrapped types. The raw pointer
50
+ // is created, used, and invalidated within a single `__kobako_dispatch`
51
+ // frame on the one Ruby thread that owns the active Invocation (SPEC.md
52
+ // Single-Invocation Slot); it is never read from another thread.
53
+ unsafe impl Send for GuestYielder {}
54
+ unsafe impl Sync for GuestYielder {}
55
+
56
+ impl GuestYielder {
57
+ /// Erase the frame-scoped `&mut dyn Yielder` into a Ruby-owned handle.
58
+ /// Safety contract for the caller: `invalidate` MUST run before the
59
+ /// borrow this pointer came from ends (i.e. before the dispatch frame
60
+ /// returns).
61
+ fn new(yielder: &mut dyn Yielder) -> Self {
62
+ let ptr = NonNull::from(yielder);
63
+ // Erase the borrow's lifetime to `'static`; the pointer is only
64
+ // ever dereferenced while it is still `Some` (i.e. `invalidate`
65
+ // has not run), so the referent is guaranteed live.
66
+ let ptr: NonNull<dyn Yielder> = unsafe {
67
+ std::mem::transmute::<NonNull<dyn Yielder + '_>, NonNull<dyn Yielder + 'static>>(ptr)
68
+ };
69
+ Self {
70
+ yielder: Cell::new(Some(ptr)),
71
+ }
72
+ }
73
+
74
+ /// Mark this handle dead. Called the instant the dispatch frame's
75
+ /// `funcall` returns, so a guest block stashed beyond its frame raises
76
+ /// instead of dereferencing freed stack.
77
+ fn invalidate(&self) {
78
+ self.yielder.set(None);
79
+ }
80
+
81
+ /// Ruby-visible `call(args_bytes) -> resp_bytes`: drive one yield
82
+ /// round-trip. Stands in for the `String -> String` callable the host
83
+ /// `Transport::Yielder` invokes. Raises `Kobako::TrapError` when the
84
+ /// handle has been invalidated (escaped guest block) or the re-entry
85
+ /// itself traps.
86
+ fn call(&self, args: RString) -> Result<RString, MagnusError> {
87
+ let ruby = Ruby::get().expect("Ruby handle unavailable in __kobako_yield");
88
+ let Some(mut ptr) = self.yielder.get() else {
89
+ return Err(super::errors::trap_err(
90
+ &ruby,
91
+ "guest block invoked after the host dispatch frame returned",
92
+ ));
93
+ };
94
+ let bytes = super::rstring_to_vec(args);
95
+ // SAFETY: `yielder` is `Some`, so `invalidate` has not run — the
96
+ // dispatch frame that lent the `&mut dyn Yielder` is still on the
97
+ // Rust stack, and the Single-Invocation Slot guarantees no other
98
+ // frame aliases it. The borrow ends with this method.
99
+ let yielder: &mut dyn Yielder = unsafe { ptr.as_mut() };
100
+ let resp = yielder
101
+ .yield_block(&bytes)
102
+ .map_err(|t| super::errors::trap_to_magnus(&ruby, t))?;
103
+ Ok(ruby.str_from_slice(&resp))
104
+ }
105
+ }
106
+
107
+ /// The Ruby-Proc bridge: a `DispatchHandler` backed by the host-side
108
+ /// dispatch `Proc` registered through `Runtime#on_dispatch=`. This is the
109
+ /// one place the dispatch seam touches `magnus`; the wasm runtime sees
110
+ /// only the trait. The Proc is GC-rooted by `Runtime`'s `mark`; this
111
+ /// struct holds an `Opaque` copy of the same handle.
112
+ pub(super) struct RubyDispatchHandler {
113
+ on_dispatch: Opaque<Value>,
114
+ }
115
+
116
+ impl RubyDispatchHandler {
117
+ pub(super) fn new(on_dispatch: Opaque<Value>) -> Self {
118
+ Self { on_dispatch }
119
+ }
120
+ }
121
+
122
+ impl DispatchHandler for RubyDispatchHandler {
123
+ /// Call the Ruby Proc with the request bytes and return the encoded
124
+ /// Response bytes. The Proc is contracted to fold every dispatch
125
+ /// failure into a `Response.err` envelope (see
126
+ /// `Kobako::Transport::Dispatcher.dispatch`), so a raise is a contract
127
+ /// violation surfaced as `None` — the dispatcher then walks the
128
+ /// 0-return wire-fault path.
129
+ fn dispatch(&self, request: &[u8], yielder: &mut dyn Yielder) -> Option<Vec<u8>> {
130
+ // The wasmtime callback runs on the same Ruby thread that called
131
+ // the active Sandbox invocation (#eval or #run) — the invariant
132
+ // SPEC Implementation Standards Architecture pins for the host gem
133
+ // — so `Ruby::get()` is always available here. Panicking with
134
+ // `expect` localises the violation rather than letting a nonsense
135
+ // error propagate.
136
+ let ruby = Ruby::get().expect("Ruby handle unavailable in __kobako_dispatch");
137
+ let proc_value: Value = ruby.get_inner(self.on_dispatch);
138
+ let req_str = ruby.str_from_slice(request);
139
+ // Hand the Proc a frame-scoped yielder object as its second arg and
140
+ // invalidate it the instant the Proc returns, so a guest block that
141
+ // escapes the dispatch frame can never deref the freed stack
142
+ // pointer. `guest_yielder` holds no Ruby Value, so it needs no GC
143
+ // mark — the GC has nothing to trace through it.
144
+ let guest_yielder = ruby.obj_wrap(GuestYielder::new(yielder));
145
+ let resp: Result<RString, magnus::Error> =
146
+ proc_value.funcall("call", (req_str, guest_yielder));
147
+ guest_yielder.invalidate();
148
+ resp.ok().map(super::rstring_to_vec)
149
+ }
150
+ }