kobako 0.4.0 → 0.5.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -0
  3. data/CHANGELOG.md +29 -0
  4. data/Cargo.lock +1 -1
  5. data/README.md +0 -1
  6. data/data/kobako.wasm +0 -0
  7. data/ext/kobako/Cargo.toml +1 -1
  8. data/ext/kobako/src/lib.rs +4 -2
  9. data/ext/kobako/src/{wasm → runtime}/cache.rs +12 -16
  10. data/ext/kobako/src/runtime/capture.rs +91 -0
  11. data/ext/kobako/src/runtime/config.rs +26 -0
  12. data/ext/kobako/src/runtime/dispatch.rs +211 -0
  13. data/ext/kobako/src/runtime/exports.rs +51 -0
  14. data/ext/kobako/src/runtime/guest_mem.rs +228 -0
  15. data/ext/kobako/src/{wasm/host_state.rs → runtime/invocation.rs} +94 -86
  16. data/ext/kobako/src/runtime/trap.rs +134 -0
  17. data/ext/kobako/src/runtime.rs +782 -0
  18. data/ext/kobako/src/snapshot.rs +110 -0
  19. data/lib/kobako/capture.rb +11 -16
  20. data/lib/kobako/catalog/handles.rb +107 -0
  21. data/lib/kobako/catalog/namespaces.rb +99 -0
  22. data/lib/kobako/{snippet/table.rb → catalog/snippets.rb} +37 -62
  23. data/lib/kobako/catalog.rb +18 -0
  24. data/lib/kobako/codec/decoder.rb +13 -5
  25. data/lib/kobako/codec/factory.rb +12 -12
  26. data/lib/kobako/codec/utils.rb +56 -59
  27. data/lib/kobako/codec.rb +6 -3
  28. data/lib/kobako/errors.rb +45 -28
  29. data/lib/kobako/fault.rb +40 -0
  30. data/lib/kobako/handle.rb +4 -6
  31. data/lib/kobako/namespace.rb +67 -0
  32. data/lib/kobako/outcome.rb +31 -35
  33. data/lib/kobako/runtime.rb +30 -0
  34. data/lib/kobako/sandbox.rb +83 -72
  35. data/lib/kobako/sandbox_options.rb +6 -9
  36. data/lib/kobako/snapshot.rb +40 -0
  37. data/lib/kobako/snippet/binary.rb +6 -7
  38. data/lib/kobako/snippet/source.rb +8 -8
  39. data/lib/kobako/snippet.rb +7 -9
  40. data/lib/kobako/transport/dispatcher.rb +195 -0
  41. data/lib/kobako/{rpc/wire_error.rb → transport/error.rb} +7 -6
  42. data/lib/kobako/transport/request.rb +78 -0
  43. data/lib/kobako/transport/response.rb +69 -0
  44. data/lib/kobako/transport/run.rb +141 -0
  45. data/lib/kobako/transport/yield.rb +91 -0
  46. data/lib/kobako/transport/yielder.rb +89 -0
  47. data/lib/kobako/transport.rb +24 -0
  48. data/lib/kobako/version.rb +1 -1
  49. data/lib/kobako.rb +4 -4
  50. data/release-please-config.json +24 -0
  51. data/sig/kobako/capture.rbs +0 -2
  52. data/sig/kobako/catalog/handles.rbs +19 -0
  53. data/sig/kobako/catalog/namespaces.rbs +17 -0
  54. data/sig/kobako/{snippet/table.rbs → catalog/snippets.rbs} +2 -11
  55. data/sig/kobako/{rpc.rbs → catalog.rbs} +1 -1
  56. data/sig/kobako/codec/decoder.rbs +2 -1
  57. data/sig/kobako/codec/factory.rbs +2 -2
  58. data/sig/kobako/codec/utils.rbs +5 -5
  59. data/sig/kobako/errors.rbs +7 -7
  60. data/sig/kobako/fault.rbs +19 -0
  61. data/sig/kobako/handle.rbs +2 -3
  62. data/sig/kobako/namespace.rbs +19 -0
  63. data/sig/kobako/outcome.rbs +2 -2
  64. data/sig/kobako/runtime.rbs +23 -0
  65. data/sig/kobako/sandbox.rbs +5 -8
  66. data/sig/kobako/snapshot.rbs +15 -0
  67. data/sig/kobako/transport/dispatcher.rbs +34 -0
  68. data/sig/kobako/transport/error.rbs +6 -0
  69. data/sig/kobako/transport/request.rbs +32 -0
  70. data/sig/kobako/transport/response.rbs +30 -0
  71. data/sig/kobako/transport/run.rbs +27 -0
  72. data/sig/kobako/transport/yield.rbs +34 -0
  73. data/sig/kobako/transport/yielder.rbs +21 -0
  74. data/sig/kobako/transport.rbs +4 -0
  75. metadata +48 -30
  76. data/ext/kobako/src/wasm/dispatch.rs +0 -162
  77. data/ext/kobako/src/wasm/instance.rs +0 -873
  78. data/ext/kobako/src/wasm.rs +0 -126
  79. data/lib/kobako/handle_table.rb +0 -119
  80. data/lib/kobako/invocation.rb +0 -143
  81. data/lib/kobako/rpc/dispatcher.rb +0 -171
  82. data/lib/kobako/rpc/envelope.rb +0 -118
  83. data/lib/kobako/rpc/fault.rb +0 -41
  84. data/lib/kobako/rpc/namespace.rb +0 -74
  85. data/lib/kobako/rpc/server.rb +0 -146
  86. data/lib/kobako/rpc.rb +0 -11
  87. data/lib/kobako/wasm.rb +0 -25
  88. data/sig/kobako/handle_table.rbs +0 -23
  89. data/sig/kobako/invocation.rbs +0 -25
  90. data/sig/kobako/rpc/dispatcher.rbs +0 -33
  91. data/sig/kobako/rpc/envelope.rbs +0 -51
  92. data/sig/kobako/rpc/fault.rbs +0 -20
  93. data/sig/kobako/rpc/namespace.rbs +0 -24
  94. data/sig/kobako/rpc/server.rbs +0 -31
  95. data/sig/kobako/rpc/wire_error.rbs +0 -6
  96. data/sig/kobako/wasm.rbs +0 -41
@@ -1,20 +1,24 @@
1
- //! Per-Store host state shared with every wasmtime callback.
1
+ //! Per-invocation host state the materialised
2
+ //! [SPEC.md Single-Invocation Slot] (one `Invocation` per OS thread
3
+ //! for the lifetime of one `Runtime::eval` / `Runtime::run` call).
2
4
  //!
3
- //! Owned by [`StoreCell`] (a `RefCell` shim wrapping `wasmtime::Store`)
5
+ //! Owned by `StoreCell` (a `RefCell` shim wrapping `wasmtime::Store`)
4
6
  //! and threaded through every host import — the `__kobako_dispatch`
5
- //! dispatcher reads the server handle, while the run-path methods on
6
- //! [`crate::wasm::Instance`] install fresh WASI context + pipes before
7
- //! every `#run` (docs/behavior.md B-03 / B-04).
7
+ //! dispatcher reads the bound dispatch Proc, while the run-path methods
8
+ //! on `crate::runtime::Runtime` install fresh WASI context + pipes
9
+ //! before every invocation (docs/behavior.md B-03 / B-04).
8
10
  //!
9
- //! The state also carries the per-invocation wall-clock deadline
11
+ //! The slot also carries the per-invocation wall-clock deadline
10
12
  //! (docs/behavior.md B-01, E-19) and the per-invocation linear-memory
11
- //! delta cap [`KobakoLimiter`] (docs/behavior.md B-01, E-20). Both are
13
+ //! delta cap `MemoryLimiter` (docs/behavior.md B-01, E-20). Both are
12
14
  //! read from the wasmtime `epoch_deadline_callback` / `ResourceLimiter`
13
- //! callbacks installed in [`crate::wasm::Instance::from_path`]. The
15
+ //! callbacks installed in `crate::runtime::Runtime::from_path`. The
14
16
  //! memory cap measures only the `memory.grow` delta past the linear-
15
17
  //! memory size captured at invocation entry — the mruby image's
16
18
  //! initial allocation and prior invocations' watermark are outside the
17
19
  //! budget.
20
+ //!
21
+ //! [SPEC.md Single-Invocation Slot]: ../../../SPEC.md
18
22
 
19
23
  use std::cell::{Ref, RefCell, RefMut};
20
24
  use std::time::{Duration, Instant};
@@ -24,46 +28,48 @@ use wasmtime::{ResourceLimiter, Store as WtStore};
24
28
  use wasmtime_wasi::p1::WasiP1Ctx;
25
29
  use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
26
30
 
27
- /// Per-Store host state threaded through every host import callback.
31
+ /// Per-invocation host state the data half of the Single-Invocation
32
+ /// Slot. Threaded through every host import callback.
28
33
  ///
29
- /// All field access is mediated by methods on this type — the WASI ctx is
30
- /// rebuilt fresh before each `#run` via [`HostState::install_wasi`], the
31
- /// Ruby Server handle is set once via [`HostState::bind_server`], and
32
- /// captured stdout/stderr bytes are read after the run via
33
- /// [`HostState::stdout_bytes`] / [`HostState::stderr_bytes`]. The fields
34
- /// are private so the mutation surface stays narrow.
35
- pub(super) struct HostState {
34
+ /// All field access is mediated by methods on this type — the WASI ctx
35
+ /// is rebuilt fresh before each invocation via
36
+ /// `Invocation::install_wasi`, the Ruby dispatch Proc is set once via
37
+ /// `Invocation::bind_on_dispatch`, and captured stdout/stderr bytes
38
+ /// are read after the invocation via `Invocation::stdout_bytes` /
39
+ /// `Invocation::stderr_bytes`. The fields are private so the mutation
40
+ /// surface stays narrow.
41
+ pub(super) struct Invocation {
36
42
  wasi: Option<WasiP1Ctx>,
37
43
  stdout_pipe: Option<MemoryOutputPipe>,
38
44
  stderr_pipe: Option<MemoryOutputPipe>,
39
- server: Option<Opaque<Value>>,
45
+ on_dispatch: Option<Opaque<Value>>,
40
46
  deadline: Option<Instant>,
41
- limiter: KobakoLimiter,
47
+ limiter: MemoryLimiter,
42
48
  wall_entry: Option<Instant>,
43
49
  wall_time: Duration,
44
50
  }
45
51
 
46
- impl HostState {
52
+ impl Invocation {
47
53
  /// Build a fresh per-Store host state. `memory_limit` carries the
48
54
  /// `Sandbox#memory_limit` cap in bytes (or `None` to disable the cap);
49
- /// it is read from the wasmtime [`ResourceLimiter`] callback every
55
+ /// it is read from the wasmtime `ResourceLimiter` callback every
50
56
  /// time the guest grows linear memory (docs/behavior.md B-01, E-20).
51
57
  pub(super) fn new(memory_limit: Option<usize>) -> Self {
52
58
  Self {
53
59
  wasi: None,
54
60
  stdout_pipe: None,
55
61
  stderr_pipe: None,
56
- server: None,
62
+ on_dispatch: None,
57
63
  deadline: None,
58
- limiter: KobakoLimiter::new(memory_limit),
64
+ limiter: MemoryLimiter::new(memory_limit),
59
65
  wall_entry: None,
60
66
  wall_time: Duration::ZERO,
61
67
  }
62
68
  }
63
69
 
64
70
  /// Install a freshly-built WASI context plus the matching stdout/stderr
65
- /// pipe clones. Called from [`crate::wasm::Instance::eval`] /
66
- /// [`crate::wasm::Instance::run`] at the top of every guest
71
+ /// pipe clones. Called from `crate::runtime::Runtime::eval` /
72
+ /// `crate::runtime::Runtime::run` at the top of every guest
67
73
  /// invocation (docs/behavior.md B-03 / B-04).
68
74
  pub(super) fn install_wasi(
69
75
  &mut self,
@@ -76,10 +82,11 @@ impl HostState {
76
82
  self.stderr_pipe = Some(stderr);
77
83
  }
78
84
 
79
- /// Bind the Ruby-side `Kobako::RPC::Server` handle. From this point on,
80
- /// every `__kobako_dispatch` host import invocation routes through it.
81
- pub(super) fn bind_server(&mut self, server: Opaque<Value>) {
82
- self.server = Some(server);
85
+ /// Register the Ruby-side dispatch +Proc+. From this point on, every
86
+ /// `__kobako_dispatch` host import invocation calls the Proc with the
87
+ /// request bytes and expects encoded Response bytes back.
88
+ pub(super) fn bind_on_dispatch(&mut self, proc_value: Opaque<Value>) {
89
+ self.on_dispatch = Some(proc_value);
83
90
  }
84
91
 
85
92
  /// Snapshot the bytes captured on guest fd 1 during the most recent
@@ -100,21 +107,22 @@ impl HostState {
100
107
  .unwrap_or_default()
101
108
  }
102
109
 
103
- /// Return the bound Server handle. `Opaque<Value>` is `Copy`, so the
104
- /// handle is returned by value rather than by reference. None means no
105
- /// Server has been bound yet via [`HostState::bind_server`].
106
- pub(super) fn server(&self) -> Option<Opaque<Value>> {
107
- self.server
110
+ /// Return the bound dispatch Proc handle. `Opaque<Value>` is `Copy`,
111
+ /// so the handle is returned by value rather than by reference. None
112
+ /// means no Proc has been bound yet via
113
+ /// `Invocation::bind_on_dispatch`.
114
+ pub(super) fn on_dispatch(&self) -> Option<Opaque<Value>> {
115
+ self.on_dispatch
108
116
  }
109
117
 
110
118
  /// Mutable handle to the live WASI context. Panics if no context has
111
119
  /// been installed yet — every call site is downstream of
112
- /// [`HostState::install_wasi`] running at the top of
113
- /// `Instance::eval` / `Instance::run`, so reaching this branch with
120
+ /// `Invocation::install_wasi` running at the top of
121
+ /// `Runtime::eval` / `Runtime::run`, so reaching this branch with
114
122
  /// `None` signals a host-side wiring bug.
115
123
  pub(super) fn wasi_mut(&mut self) -> &mut WasiP1Ctx {
116
124
  self.wasi.as_mut().expect(
117
- "WASI context not initialised — call Instance#eval / Instance#run before any WASI use",
125
+ "WASI context not initialised — call Runtime#eval / Runtime#run before any WASI use",
118
126
  )
119
127
  }
120
128
 
@@ -126,19 +134,19 @@ impl HostState {
126
134
  }
127
135
 
128
136
  /// Return the current per-run deadline. Read from the epoch-deadline
129
- /// callback installed by [`crate::wasm::Instance::from_path`].
137
+ /// callback installed by `crate::runtime::Runtime::from_path`.
130
138
  pub(super) fn deadline(&self) -> Option<Instant> {
131
139
  self.deadline
132
140
  }
133
141
 
134
- /// Mutable handle to the embedded [`KobakoLimiter`]. Required by
135
- /// the wasmtime [`ResourceLimiter`] callback wiring in
136
- /// [`crate::wasm::Instance::from_path`]
142
+ /// Mutable handle to the embedded `MemoryLimiter`. Required by
143
+ /// the wasmtime `ResourceLimiter` callback wiring in
144
+ /// `crate::runtime::Runtime::from_path`
137
145
  /// (`store.limiter(|state| state.limiter_mut())`); kept private to
138
146
  /// the wasm submodule so the only public surface for arming the
139
- /// cap goes through [`HostState::arm_memory_cap`] /
140
- /// [`HostState::disarm_memory_cap`].
141
- pub(super) fn limiter_mut(&mut self) -> &mut KobakoLimiter {
147
+ /// cap goes through `Invocation::arm_memory_cap` /
148
+ /// `Invocation::disarm_memory_cap`.
149
+ pub(super) fn limiter_mut(&mut self) -> &mut MemoryLimiter {
142
150
  &mut self.limiter
143
151
  }
144
152
 
@@ -147,7 +155,7 @@ impl HostState {
147
155
  /// charges only the `memory.grow` delta past `baseline` against
148
156
  /// the cap, so the mruby image's initial allocation and the
149
157
  /// high-water mark left by prior invocations do not consume the
150
- /// budget. Paired with [`HostState::disarm_memory_cap`] around the
158
+ /// budget. Paired with `Invocation::disarm_memory_cap` around the
151
159
  /// call to the corresponding `__kobako_*` export so post-run host
152
160
  /// bookkeeping (e.g. fetching the OUTCOME_BUFFER) is not
153
161
  /// attributed to the user script.
@@ -156,7 +164,7 @@ impl HostState {
156
164
  }
157
165
 
158
166
  /// Disarm the docs/behavior.md E-20 memory cap. See
159
- /// [`HostState::arm_memory_cap`].
167
+ /// `Invocation::arm_memory_cap`.
160
168
  pub(super) fn disarm_memory_cap(&mut self) {
161
169
  self.limiter.deactivate();
162
170
  }
@@ -172,7 +180,7 @@ impl HostState {
172
180
  }
173
181
 
174
182
  /// Close the docs/behavior.md B-35 `wall_time` measurement
175
- /// started by [`HostState::start_wall_clock`]. Idempotent — a
183
+ /// started by `Invocation::start_wall_clock`. Idempotent — a
176
184
  /// stop with no matching start (e.g. if the guest export call
177
185
  /// never executed because of a host-side allocation failure)
178
186
  /// leaves the previously-recorded value untouched.
@@ -203,30 +211,30 @@ impl HostState {
203
211
  ///
204
212
  /// `max_memory` is the byte cap on per-invocation growth (`None` disables
205
213
  /// the cap). `baseline` is the linear-memory size captured at invocation
206
- /// entry by [`KobakoLimiter::activate`]; the limiter charges only the
214
+ /// entry by `MemoryLimiter::activate`; the limiter charges only the
207
215
  /// `memory.grow` delta past `baseline` against `max_memory`, so the
208
216
  /// mruby image's initial allocation and any high-water mark left by
209
217
  /// prior invocations on the same Sandbox do not consume the budget.
210
218
  /// `cap_active` gates whether the cap is enforced — wasmtime's
211
219
  /// `ResourceLimiter` also fires for the module's declared initial
212
220
  /// allocation at instantiation time, but the cap stays dormant until
213
- /// [`KobakoLimiter::activate`] flips the flag for one
214
- /// `Instance::eval` / `Instance::run` call. When `cap_active` is
221
+ /// `MemoryLimiter::activate` flips the flag for one
222
+ /// `Runtime::eval` / `Runtime::run` call. When `cap_active` is
215
223
  /// `false`, the limiter always allows growth.
216
224
  ///
217
225
  /// When `memory.grow` would push the per-invocation delta past
218
- /// `max_memory`, the limiter returns [`MemoryLimitTrap`] from
226
+ /// `max_memory`, the limiter returns `MemoryLimitTrap` from
219
227
  /// `memory_growing`; wasmtime turns that into the trap surfaced to the
220
228
  /// host as a guest invocation failure.
221
229
  #[derive(Debug, Clone, Copy)]
222
- pub(super) struct KobakoLimiter {
230
+ pub(super) struct MemoryLimiter {
223
231
  max_memory: Option<usize>,
224
232
  baseline: usize,
225
233
  cap_active: bool,
226
234
  peak: usize,
227
235
  }
228
236
 
229
- impl KobakoLimiter {
237
+ impl MemoryLimiter {
230
238
  fn new(max_memory: Option<usize>) -> Self {
231
239
  Self {
232
240
  max_memory,
@@ -238,12 +246,12 @@ impl KobakoLimiter {
238
246
 
239
247
  /// Arm the cap so subsequent `memory.grow` calls are charged
240
248
  /// against `max_memory` starting from `baseline` bytes. Called via
241
- /// [`HostState::arm_memory_cap`] at the top of every invocation;
249
+ /// `Invocation::arm_memory_cap` at the top of every invocation;
242
250
  /// the cap is dormant by default — the module's declared initial
243
251
  /// memory is allocated during `Linker::instantiate` and the
244
252
  /// per-invocation budget excludes anything that existed before
245
253
  /// arming (docs/behavior.md B-01 Notes, E-20). Also clears the
246
- /// per-invocation [`KobakoLimiter::peak`] high-water so the
254
+ /// per-invocation `MemoryLimiter::peak` high-water so the
247
255
  /// docs/behavior.md B-35 `memory_peak` accounting restarts from
248
256
  /// zero for the new invocation.
249
257
  fn activate(&mut self, baseline: usize) {
@@ -255,14 +263,14 @@ impl KobakoLimiter {
255
263
  /// Disarm the cap so post-run host bookkeeping (e.g. fetching the
256
264
  /// OUTCOME_BUFFER, which can grow guest memory transiently) is
257
265
  /// not attributed to the user script. Paired with
258
- /// [`KobakoLimiter::activate`].
266
+ /// `MemoryLimiter::activate`.
259
267
  fn deactivate(&mut self) {
260
268
  self.cap_active = false;
261
269
  }
262
270
 
263
271
  /// Return the high-water mark of the per-invocation
264
272
  /// `memory.grow` delta past `baseline` observed since the last
265
- /// [`KobakoLimiter::activate`]. Read after the guest export
273
+ /// `MemoryLimiter::activate`. Read after the guest export
266
274
  /// returns to populate `Kobako::Usage#memory_peak`
267
275
  /// (docs/behavior.md B-35). Pinned to the last accepted grow —
268
276
  /// rejected `desired` values that trip the docs/behavior.md E-20
@@ -273,7 +281,7 @@ impl KobakoLimiter {
273
281
  }
274
282
  }
275
283
 
276
- impl ResourceLimiter for KobakoLimiter {
284
+ impl ResourceLimiter for MemoryLimiter {
277
285
  fn memory_growing(
278
286
  &mut self,
279
287
  _current: usize,
@@ -305,9 +313,9 @@ impl ResourceLimiter for KobakoLimiter {
305
313
  }
306
314
  }
307
315
 
308
- /// Marker error returned from [`KobakoLimiter::memory_growing`] on
316
+ /// Marker error returned from `MemoryLimiter::memory_growing` on
309
317
  /// docs/behavior.md E-20. Downcast from the wasmtime trap error to surface as
310
- /// `Kobako::Wasm::MemoryLimitError` on the Ruby side. Callers use the
318
+ /// `Kobako::MemoryLimitError` on the Ruby side. Callers use the
311
319
  /// `Display` impl below — no field is read directly — so the inner
312
320
  /// state stays private.
313
321
  #[derive(Debug)]
@@ -318,7 +326,7 @@ pub(crate) struct MemoryLimitTrap {
318
326
 
319
327
  impl MemoryLimitTrap {
320
328
  /// Construct a trap with the given +desired+ / +limit+ pair. Used
321
- /// internally by [`KobakoLimiter::memory_growing`] in production and
329
+ /// internally by `MemoryLimiter::memory_growing` in production and
322
330
  /// by the sibling-module +classify_trap+ unit tests to materialise
323
331
  /// a representative error for downcast routing.
324
332
  #[cfg(test)]
@@ -331,8 +339,8 @@ impl std::fmt::Display for MemoryLimitTrap {
331
339
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332
340
  write!(
333
341
  f,
334
- "linear memory growth exceeded memory_limit: \
335
- desired={} bytes, limit={} bytes",
342
+ "memory usage exceeded memory_limit: \
343
+ requested={} bytes, limit={} bytes",
336
344
  self.desired, self.limit
337
345
  )
338
346
  }
@@ -342,7 +350,7 @@ impl std::error::Error for MemoryLimitTrap {}
342
350
 
343
351
  /// Marker error returned from the epoch-deadline callback on
344
352
  /// docs/behavior.md E-19. Downcast from the wasmtime trap error to
345
- /// surface as `Kobako::Wasm::TimeoutError` on the Ruby side.
353
+ /// surface as `Kobako::TimeoutError` on the Ruby side.
346
354
  #[derive(Debug)]
347
355
  pub(crate) struct TimeoutTrap;
348
356
 
@@ -354,20 +362,20 @@ impl std::fmt::Display for TimeoutTrap {
354
362
 
355
363
  impl std::error::Error for TimeoutTrap {}
356
364
 
357
- /// Interior-mutability wrapper around `wasmtime::Store<HostState>`.
365
+ /// Interior-mutability wrapper around `wasmtime::Store<Invocation>`.
358
366
  ///
359
367
  /// Magnus requires `Send + Sync` for wrapped types. `wasmtime::Store` is not
360
368
  /// `Sync`, so we wrap it in a `RefCell`. `RefCell` alone is sufficient
361
369
  /// because magnus enforces single-threaded GVL access from Ruby; `Send` and
362
370
  /// `Sync` are asserted via the unsafe impls below.
363
371
  pub(super) struct StoreCell {
364
- inner: RefCell<WtStore<HostState>>,
372
+ inner: RefCell<WtStore<Invocation>>,
365
373
  }
366
374
 
367
375
  impl StoreCell {
368
- /// Wrap a freshly-built `wasmtime::Store<HostState>` so it can be owned
369
- /// by the magnus-wrapped `Instance`.
370
- pub(super) fn new(store: WtStore<HostState>) -> Self {
376
+ /// Wrap a freshly-built `wasmtime::Store<Invocation>` so it can be owned
377
+ /// by the magnus-wrapped `Runtime`.
378
+ pub(super) fn new(store: WtStore<Invocation>) -> Self {
371
379
  Self {
372
380
  inner: RefCell::new(store),
373
381
  }
@@ -375,13 +383,13 @@ impl StoreCell {
375
383
 
376
384
  /// Immutable borrow of the wrapped Store. Panics if a `borrow_mut()`
377
385
  /// is currently live — matches `RefCell::borrow` semantics.
378
- pub(super) fn borrow(&self) -> Ref<'_, WtStore<HostState>> {
386
+ pub(super) fn borrow(&self) -> Ref<'_, WtStore<Invocation>> {
379
387
  self.inner.borrow()
380
388
  }
381
389
 
382
390
  /// Mutable borrow of the wrapped Store. Panics if any other borrow is
383
391
  /// currently live — matches `RefCell::borrow_mut` semantics.
384
- pub(super) fn borrow_mut(&self) -> RefMut<'_, WtStore<HostState>> {
392
+ pub(super) fn borrow_mut(&self) -> RefMut<'_, WtStore<Invocation>> {
385
393
  self.inner.borrow_mut()
386
394
  }
387
395
  }
@@ -389,10 +397,10 @@ impl StoreCell {
389
397
  // SAFETY: magnus requires `Send + Sync` on `#[magnus::wrap]` types. Both
390
398
  // claims hold under the GVL invariant:
391
399
  //
392
- // * Send — `wasmtime::Store<HostState>` is itself `Send` (verified
400
+ // * Send — `wasmtime::Store<Invocation>` is itself `Send` (verified
393
401
  // upstream by wasmtime; see `wasmtime::Store`'s trait impls).
394
402
  // `RefCell<T>: Send` whenever `T: Send`. The remaining stored state
395
- // (`HostState`) holds `Opaque<Value>` for the Ruby Server handle —
403
+ // (`Invocation`) holds `Opaque<Value>` for the Ruby Server handle —
396
404
  // `Opaque<Value>` is documented as `Send` by magnus precisely so
397
405
  // wrapped objects can satisfy this bound.
398
406
  //
@@ -412,24 +420,24 @@ unsafe impl Sync for StoreCell {}
412
420
 
413
421
  #[cfg(test)]
414
422
  mod tests {
415
- //! Unit tests for [`KobakoLimiter`] — the per-invocation memory
423
+ //! Unit tests for `MemoryLimiter` — the per-invocation memory
416
424
  //! delta cap. The Ruby-facing E2E suite exercises the full path
417
425
  //! through wasmtime; these tests pin the pure delta arithmetic so
418
426
  //! a regression that breaks the baseline accounting (e.g. dropping
419
427
  //! the `baseline` subtraction, or letting `activate` carry stale
420
428
  //! state across invocations) is caught without spinning up a
421
429
  //! Store.
422
- use super::{KobakoLimiter, MemoryLimitTrap};
430
+ use super::{MemoryLimitTrap, MemoryLimiter};
423
431
  use wasmtime::ResourceLimiter;
424
432
 
425
- fn assert_growing(limiter: &mut KobakoLimiter, desired: usize) {
433
+ fn assert_growing(limiter: &mut MemoryLimiter, desired: usize) {
426
434
  assert!(
427
435
  limiter.memory_growing(0, desired, None).unwrap(),
428
436
  "expected memory_growing({desired}) to allow growth"
429
437
  );
430
438
  }
431
439
 
432
- fn assert_trapping(limiter: &mut KobakoLimiter, desired: usize) {
440
+ fn assert_trapping(limiter: &mut MemoryLimiter, desired: usize) {
433
441
  let err = limiter
434
442
  .memory_growing(0, desired, None)
435
443
  .expect_err("expected memory_growing to trap");
@@ -441,7 +449,7 @@ mod tests {
441
449
 
442
450
  #[test]
443
451
  fn dormant_limiter_allows_any_growth() {
444
- let mut limiter = KobakoLimiter::new(Some(1 << 20));
452
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
445
453
  // Without `activate`, the cap is dormant — the module's
446
454
  // declared initial allocation must pass through unconditionally.
447
455
  assert_growing(&mut limiter, 100 << 20);
@@ -449,7 +457,7 @@ mod tests {
449
457
 
450
458
  #[test]
451
459
  fn delta_below_cap_passes_after_activate() {
452
- let mut limiter = KobakoLimiter::new(Some(1 << 20));
460
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
453
461
  limiter.activate(2 << 20);
454
462
  // baseline=2 MiB, desired=2.5 MiB → delta=0.5 MiB ≤ 1 MiB cap.
455
463
  assert_growing(&mut limiter, (2 << 20) + (1 << 19));
@@ -457,7 +465,7 @@ mod tests {
457
465
 
458
466
  #[test]
459
467
  fn delta_past_cap_traps_with_memory_limit_trap() {
460
- let mut limiter = KobakoLimiter::new(Some(1 << 20));
468
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
461
469
  limiter.activate(2 << 20);
462
470
  // baseline=2 MiB, desired=4 MiB → delta=2 MiB > 1 MiB cap.
463
471
  assert_trapping(&mut limiter, 4 << 20);
@@ -465,7 +473,7 @@ mod tests {
465
473
 
466
474
  #[test]
467
475
  fn activate_resets_baseline_on_each_invocation() {
468
- let mut limiter = KobakoLimiter::new(Some(1 << 20));
476
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
469
477
  limiter.activate(2 << 20);
470
478
  assert_growing(&mut limiter, (2 << 20) + (1 << 20));
471
479
  // Second invocation: linear memory has grown to 3 MiB. Re-arming
@@ -478,20 +486,20 @@ mod tests {
478
486
 
479
487
  #[test]
480
488
  fn disabled_cap_ignores_delta_size() {
481
- let mut limiter = KobakoLimiter::new(None);
489
+ let mut limiter = MemoryLimiter::new(None);
482
490
  limiter.activate(0);
483
491
  assert_growing(&mut limiter, 100 << 20);
484
492
  }
485
493
 
486
494
  #[test]
487
495
  fn peak_starts_at_zero_before_any_grow() {
488
- let limiter = KobakoLimiter::new(Some(1 << 20));
496
+ let limiter = MemoryLimiter::new(Some(1 << 20));
489
497
  assert_eq!(limiter.peak(), 0);
490
498
  }
491
499
 
492
500
  #[test]
493
501
  fn peak_tracks_high_water_of_delta_past_baseline() {
494
- let mut limiter = KobakoLimiter::new(Some(1 << 20));
502
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
495
503
  limiter.activate(2 << 20);
496
504
  assert_growing(&mut limiter, (2 << 20) + (1 << 18)); // delta=256 KiB
497
505
  assert_growing(&mut limiter, (2 << 20) + (1 << 19)); // delta=512 KiB (new peak)
@@ -501,7 +509,7 @@ mod tests {
501
509
 
502
510
  #[test]
503
511
  fn trap_does_not_update_peak() {
504
- let mut limiter = KobakoLimiter::new(Some(1 << 20));
512
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
505
513
  limiter.activate(2 << 20);
506
514
  assert_growing(&mut limiter, (2 << 20) + (1 << 19)); // delta=512 KiB
507
515
  assert_trapping(&mut limiter, (2 << 20) + (2 << 20)); // would be 2 MiB > 1 MiB cap
@@ -511,7 +519,7 @@ mod tests {
511
519
 
512
520
  #[test]
513
521
  fn activate_resets_peak_for_new_invocation() {
514
- let mut limiter = KobakoLimiter::new(Some(1 << 20));
522
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
515
523
  limiter.activate(2 << 20);
516
524
  assert_growing(&mut limiter, (2 << 20) + (1 << 19));
517
525
  assert_eq!(limiter.peak(), 1 << 19);
@@ -521,7 +529,7 @@ mod tests {
521
529
 
522
530
  #[test]
523
531
  fn disabled_cap_still_tracks_peak() {
524
- let mut limiter = KobakoLimiter::new(None);
532
+ let mut limiter = MemoryLimiter::new(None);
525
533
  limiter.activate(1 << 20);
526
534
  assert_growing(&mut limiter, (1 << 20) + (4 << 20));
527
535
  assert_eq!(limiter.peak(), 4 << 20);
@@ -0,0 +1,134 @@
1
+ //! Trap classification for the run path.
2
+ //!
3
+ //! Maps a `wasmtime` run error to the right top-level `Kobako::*` Ruby
4
+ //! exception (`TimeoutError` / `MemoryLimitError` / `TrapError`), and
5
+ //! hosts the epoch-deadline callback that raises the wall-clock
6
+ //! `TimeoutTrap`. The classification is a pure function over the error's
7
+ //! downcast chain so it can be exercised from `cargo test` without the
8
+ //! magnus surface; the trap marker types themselves live in
9
+ //! `super::invocation` (where the limiter / callback construct them).
10
+
11
+ use std::time::Instant;
12
+
13
+ use magnus::{Error as MagnusError, Ruby};
14
+ use wasmtime::{StoreContextMut, UpdateDeadline};
15
+
16
+ use super::invocation::{Invocation, MemoryLimitTrap, TimeoutTrap};
17
+ use super::{memory_limit_err, setup_err, timeout_err, trap_err};
18
+
19
+ /// Epoch-deadline callback installed on every Store. Read the per-run
20
+ /// wall-clock deadline from `Invocation` (docs/behavior.md B-01) and trap with
21
+ /// `TimeoutTrap` once the deadline has passed; otherwise extend the
22
+ /// 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
25
+ /// `set_epoch_deadline(u64::MAX)` is used; returning a long extension
26
+ /// keeps the callback inert as a defence in depth.
27
+ pub(super) fn epoch_deadline_callback(
28
+ ctx: StoreContextMut<'_, Invocation>,
29
+ ) -> wasmtime::Result<UpdateDeadline> {
30
+ match ctx.data().deadline() {
31
+ Some(deadline) if Instant::now() >= deadline => Err(wasmtime::Error::new(TimeoutTrap)),
32
+ Some(_) => Ok(UpdateDeadline::Continue(1)),
33
+ None => Ok(UpdateDeadline::Continue(u64::MAX / 2)),
34
+ }
35
+ }
36
+
37
+ /// Configured-cap path classification for a wasmtime error. The
38
+ /// downcast logic stays in a pure helper so the
39
+ /// `Kobako::TimeoutError` / `MemoryLimitError` /
40
+ /// `Kobako::TrapError` mapping can be exercised from `cargo test`
41
+ /// without the magnus surface.
42
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
43
+ enum TrapClass {
44
+ /// docs/behavior.md E-19 wall-clock cap path.
45
+ Timeout,
46
+ /// docs/behavior.md E-20 linear-memory cap path.
47
+ MemoryLimit,
48
+ /// Any other wasmtime error — surfaces as the base
49
+ /// `Kobako::TrapError`.
50
+ Other,
51
+ }
52
+
53
+ /// Inspect a wasmtime error to decide which top-level `Kobako::*` trap
54
+ /// class it should map to. Pure function — operates on the error's
55
+ /// downcast chain only, no magnus / Ruby state required.
56
+ fn classify_trap(err: &wasmtime::Error) -> TrapClass {
57
+ if err.downcast_ref::<TimeoutTrap>().is_some() {
58
+ TrapClass::Timeout
59
+ } else if err.downcast_ref::<MemoryLimitTrap>().is_some() {
60
+ TrapClass::MemoryLimit
61
+ } else {
62
+ TrapClass::Other
63
+ }
64
+ }
65
+
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.
72
+ ///
73
+ /// For the configured-cap paths (`TrapClass::Timeout` /
74
+ /// `TrapClass::MemoryLimit`) the trap's own `std::fmt::Display`
75
+ /// carries the user-facing reason (`"wall-clock deadline exceeded"`,
76
+ /// `"linear memory growth exceeded memory_limit: ..."`). The wasmtime
77
+ /// outer wrapper at `format!("{}", err)` would otherwise surface only
78
+ /// the `"error while executing at wasm backtrace: ..."` framing, which
79
+ /// is operator noise on a cap trap. For `TrapClass::Other` the
80
+ /// wasmtime wrapper IS the diagnostic (real script trap) so it stays.
81
+ pub(super) fn call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
82
+ match classify_trap(&err) {
83
+ TrapClass::Timeout => {
84
+ let msg = err
85
+ .downcast_ref::<TimeoutTrap>()
86
+ .map(|t| t.to_string())
87
+ .unwrap_or_else(|| format!("{}", err));
88
+ timeout_err(ruby, msg)
89
+ }
90
+ TrapClass::MemoryLimit => {
91
+ let msg = err
92
+ .downcast_ref::<MemoryLimitTrap>()
93
+ .map(|t| t.to_string())
94
+ .unwrap_or_else(|| format!("{}", err));
95
+ memory_limit_err(ruby, msg)
96
+ }
97
+ TrapClass::Other => trap_err(ruby, format!("{}", err)),
98
+ }
99
+ }
100
+
101
+ /// Map an instantiation error to `Kobako::SetupError`. Instantiation runs
102
+ /// during `from_path` construction, before any invocation — docs/behavior.md
103
+ /// E-41 classifies every such failure as a construction setup fault, not a
104
+ /// per-invocation cap outcome. The memory cap is dormant during
105
+ /// instantiation (see `Invocation::arm_memory_cap` /
106
+ /// `Invocation::disarm_memory_cap`) and the epoch deadline is not yet
107
+ /// armed, so the `call_err` trap-class split does not apply here.
108
+ pub(super) fn instantiate_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
109
+ setup_err(ruby, format!("instantiate: {}", err))
110
+ }
111
+
112
+ #[cfg(test)]
113
+ mod tests {
114
+ use super::{classify_trap, TrapClass};
115
+ use crate::runtime::invocation::{MemoryLimitTrap, TimeoutTrap};
116
+
117
+ #[test]
118
+ fn classify_trap_routes_timeout_trap_to_timeout() {
119
+ let err = wasmtime::Error::new(TimeoutTrap);
120
+ assert_eq!(classify_trap(&err), TrapClass::Timeout);
121
+ }
122
+
123
+ #[test]
124
+ fn classify_trap_routes_memory_limit_trap_to_memory_limit() {
125
+ let err = wasmtime::Error::new(MemoryLimitTrap::new(1 << 20, 1 << 19));
126
+ assert_eq!(classify_trap(&err), TrapClass::MemoryLimit);
127
+ }
128
+
129
+ #[test]
130
+ fn classify_trap_falls_back_to_other_for_unknown_errors() {
131
+ let err = wasmtime::Error::msg("some other wasmtime fault");
132
+ assert_eq!(classify_trap(&err), TrapClass::Other);
133
+ }
134
+ }