kobako 0.2.1 → 0.3.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +123 -57
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +2 -2
  6. data/ext/kobako/src/wasm/cache.rs +3 -3
  7. data/ext/kobako/src/wasm/dispatch.rs +87 -36
  8. data/ext/kobako/src/wasm/host_state.rs +189 -52
  9. data/ext/kobako/src/wasm/instance.rs +367 -152
  10. data/ext/kobako/src/wasm.rs +19 -5
  11. data/lib/kobako/capture.rb +12 -10
  12. data/lib/kobako/codec/decoder.rb +3 -2
  13. data/lib/kobako/codec/encoder.rb +1 -1
  14. data/lib/kobako/codec/error.rb +3 -2
  15. data/lib/kobako/codec/factory.rb +11 -7
  16. data/lib/kobako/codec/utils.rb +3 -2
  17. data/lib/kobako/codec.rb +2 -1
  18. data/lib/kobako/errors.rb +22 -10
  19. data/lib/kobako/invocation.rb +112 -0
  20. data/lib/kobako/outcome/panic.rb +2 -2
  21. data/lib/kobako/outcome.rb +20 -13
  22. data/lib/kobako/rpc/dispatcher.rb +9 -9
  23. data/lib/kobako/rpc/envelope.rb +3 -3
  24. data/lib/kobako/rpc/fault.rb +3 -2
  25. data/lib/kobako/rpc/handle.rb +3 -2
  26. data/lib/kobako/rpc/handle_table.rb +7 -7
  27. data/lib/kobako/rpc/namespace.rb +3 -3
  28. data/lib/kobako/rpc/server.rb +14 -12
  29. data/lib/kobako/sandbox.rb +147 -125
  30. data/lib/kobako/sandbox_options.rb +73 -0
  31. data/lib/kobako/snippet/binary.rb +30 -0
  32. data/lib/kobako/snippet/source.rb +28 -0
  33. data/lib/kobako/snippet/table.rb +174 -0
  34. data/lib/kobako/snippet.rb +20 -0
  35. data/lib/kobako/version.rb +1 -1
  36. data/sig/kobako/errors.rbs +3 -0
  37. data/sig/kobako/invocation.rbs +23 -0
  38. data/sig/kobako/sandbox.rbs +17 -18
  39. data/sig/kobako/sandbox_options.rbs +32 -0
  40. data/sig/kobako/snippet/binary.rbs +12 -0
  41. data/sig/kobako/snippet/source.rbs +13 -0
  42. data/sig/kobako/snippet/table.rbs +36 -0
  43. data/sig/kobako/snippet.rbs +4 -0
  44. data/sig/kobako/wasm.rbs +3 -1
  45. metadata +13 -1
@@ -4,13 +4,17 @@
4
4
  //! and threaded through every host import — the `__kobako_dispatch`
5
5
  //! dispatcher reads the server handle, while the run-path methods on
6
6
  //! [`crate::wasm::Instance`] install fresh WASI context + pipes before
7
- //! every `#run` (SPEC.md B-03 / B-04).
7
+ //! every `#run` (docs/behavior.md B-03 / B-04).
8
8
  //!
9
- //! The state also carries the per-run wall-clock deadline (SPEC.md B-01,
10
- //! E-19) and the linear-memory cap [`KobakoLimiter`] (SPEC.md B-01,
11
- //! E-20). Both are read from the wasmtime `epoch_deadline_callback` /
12
- //! `ResourceLimiter` callbacks installed in
13
- //! [`crate::wasm::Instance::from_path`].
9
+ //! The state also carries the per-invocation wall-clock deadline
10
+ //! (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
12
+ //! read from the wasmtime `epoch_deadline_callback` / `ResourceLimiter`
13
+ //! callbacks installed in [`crate::wasm::Instance::from_path`]. The
14
+ //! memory cap measures only the `memory.grow` delta past the linear-
15
+ //! memory size captured at invocation entry — the mruby image's
16
+ //! initial allocation and prior invocations' watermark are outside the
17
+ //! budget.
14
18
 
15
19
  use std::cell::{Ref, RefCell, RefMut};
16
20
  use std::time::Instant;
@@ -41,7 +45,7 @@ impl HostState {
41
45
  /// Build a fresh per-Store host state. `memory_limit` carries the
42
46
  /// `Sandbox#memory_limit` cap in bytes (or `None` to disable the cap);
43
47
  /// it is read from the wasmtime [`ResourceLimiter`] callback every
44
- /// time the guest grows linear memory (SPEC.md B-01, E-20).
48
+ /// time the guest grows linear memory (docs/behavior.md B-01, E-20).
45
49
  pub(super) fn new(memory_limit: Option<usize>) -> Self {
46
50
  Self {
47
51
  wasi: None,
@@ -54,8 +58,9 @@ impl HostState {
54
58
  }
55
59
 
56
60
  /// Install a freshly-built WASI context plus the matching stdout/stderr
57
- /// pipe clones. Called from [`crate::wasm::Instance::run`] at the top
58
- /// of every guest invocation (SPEC.md B-03 / B-04).
61
+ /// pipe clones. Called from [`crate::wasm::Instance::eval`] /
62
+ /// [`crate::wasm::Instance::run`] at the top of every guest
63
+ /// invocation (docs/behavior.md B-03 / B-04).
59
64
  pub(super) fn install_wasi(
60
65
  &mut self,
61
66
  wasi: WasiP1Ctx,
@@ -100,17 +105,18 @@ impl HostState {
100
105
 
101
106
  /// Mutable handle to the live WASI context. Panics if no context has
102
107
  /// been installed yet — every call site is downstream of
103
- /// [`HostState::install_wasi`] running at the top of `Instance::run`,
104
- /// so reaching this branch with `None` signals a host-side wiring bug.
108
+ /// [`HostState::install_wasi`] running at the top of
109
+ /// `Instance::eval` / `Instance::run`, so reaching this branch with
110
+ /// `None` signals a host-side wiring bug.
105
111
  pub(super) fn wasi_mut(&mut self) -> &mut WasiP1Ctx {
106
- self.wasi
107
- .as_mut()
108
- .expect("WASI context not initialised — call Instance#run before any WASI use")
112
+ self.wasi.as_mut().expect(
113
+ "WASI context not initialised — call Instance#eval / Instance#run before any WASI use",
114
+ )
109
115
  }
110
116
 
111
117
  /// Replace the per-run wall-clock deadline. `Some(at)` makes the
112
118
  /// epoch-deadline callback trap once `Instant::now() >= at`; `None`
113
- /// disables the cap. Called at the top of every `#run` (SPEC.md B-01).
119
+ /// disables the cap. Called at the top of every `#run` (docs/behavior.md B-01).
114
120
  pub(super) fn set_deadline(&mut self, deadline: Option<Instant>) {
115
121
  self.deadline = deadline;
116
122
  }
@@ -121,36 +127,61 @@ impl HostState {
121
127
  self.deadline
122
128
  }
123
129
 
124
- /// Mutable handle to the embedded [`KobakoLimiter`]. Shared by the
125
- /// wasmtime [`ResourceLimiter`] callback (set once at Store build
126
- /// time) and by [`crate::wasm::Instance`] for arming / disarming the
127
- /// memory cap around each guest run. Same shape as
128
- /// [`HostState::wasi_mut`] callers operate on the inner type
129
- /// directly instead of going through a per-action passthrough.
130
+ /// Mutable handle to the embedded [`KobakoLimiter`]. Required by
131
+ /// the wasmtime [`ResourceLimiter`] callback wiring in
132
+ /// [`crate::wasm::Instance::from_path`]
133
+ /// (`store.limiter(|state| state.limiter_mut())`); kept private to
134
+ /// the wasm submodule so the only public surface for arming the
135
+ /// cap goes through [`HostState::arm_memory_cap`] /
136
+ /// [`HostState::disarm_memory_cap`].
130
137
  pub(super) fn limiter_mut(&mut self) -> &mut KobakoLimiter {
131
138
  &mut self.limiter
132
139
  }
140
+
141
+ /// Arm the docs/behavior.md E-20 memory cap for one guest run with
142
+ /// the current linear-memory size as the baseline. The limiter
143
+ /// charges only the `memory.grow` delta past `baseline` against
144
+ /// the cap, so the mruby image's initial allocation and the
145
+ /// high-water mark left by prior invocations do not consume the
146
+ /// budget. Paired with [`HostState::disarm_memory_cap`] around the
147
+ /// call to the corresponding `__kobako_*` export so post-run host
148
+ /// bookkeeping (e.g. fetching the OUTCOME_BUFFER) is not
149
+ /// attributed to the user script.
150
+ pub(super) fn arm_memory_cap(&mut self, baseline: usize) {
151
+ self.limiter.activate(baseline);
152
+ }
153
+
154
+ /// Disarm the docs/behavior.md E-20 memory cap. See
155
+ /// [`HostState::arm_memory_cap`].
156
+ pub(super) fn disarm_memory_cap(&mut self) {
157
+ self.limiter.deactivate();
158
+ }
133
159
  }
134
160
 
135
- /// Resource limiter that enforces the `memory_limit` cap from SPEC.md
136
- /// B-01 / E-20 on every guest `memory.grow`.
161
+ /// Resource limiter that enforces the per-invocation `memory_limit`
162
+ /// cap from docs/behavior.md B-01 / E-20.
137
163
  ///
138
- /// `max_memory` is the byte cap (`None` disables the cap). `cap_active`
139
- /// gates whether the cap is enforced wasmtime's `ResourceLimiter`
140
- /// fires for both the module's declared initial allocation and every
141
- /// subsequent `memory.grow`, but SPEC.md E-20 scopes the trap to
142
- /// `memory.grow` specifically. [`KobakoLimiter::activate`] /
143
- /// [`KobakoLimiter::deactivate`] flip the flag for the lifetime of an
144
- /// `Instance::run` call. When `cap_active` is `false`, the limiter
145
- /// always allows growth.
164
+ /// `max_memory` is the byte cap on per-invocation growth (`None` disables
165
+ /// the cap). `baseline` is the linear-memory size captured at invocation
166
+ /// entry by [`KobakoLimiter::activate`]; the limiter charges only the
167
+ /// `memory.grow` delta past `baseline` against `max_memory`, so the
168
+ /// mruby image's initial allocation and any high-water mark left by
169
+ /// prior invocations on the same Sandbox do not consume the budget.
170
+ /// `cap_active` gates whether the cap is enforced wasmtime's
171
+ /// `ResourceLimiter` also fires for the module's declared initial
172
+ /// allocation at instantiation time, but the cap stays dormant until
173
+ /// [`KobakoLimiter::activate`] flips the flag for one
174
+ /// `Instance::eval` / `Instance::run` call. When `cap_active` is
175
+ /// `false`, the limiter always allows growth.
146
176
  ///
147
- /// When `memory.grow` would push linear memory past the cap, the
148
- /// limiter returns [`MemoryLimitTrap`] from `memory_growing`; wasmtime
149
- /// turns that into the trap surfaced to the host as `__kobako_run`
150
- /// failure.
177
+ /// When `memory.grow` would push the per-invocation delta past
178
+ /// `max_memory`, the limiter returns [`MemoryLimitTrap`] from
179
+ /// `memory_growing`; wasmtime turns that into the trap surfaced to the
180
+ /// host as a guest invocation failure.
151
181
  #[derive(Debug, Clone, Copy)]
152
182
  pub(super) struct KobakoLimiter {
153
183
  max_memory: Option<usize>,
184
+ baseline: usize,
154
185
  cap_active: bool,
155
186
  }
156
187
 
@@ -158,18 +189,20 @@ impl KobakoLimiter {
158
189
  fn new(max_memory: Option<usize>) -> Self {
159
190
  Self {
160
191
  max_memory,
192
+ baseline: 0,
161
193
  cap_active: false,
162
194
  }
163
195
  }
164
196
 
165
- /// Arm the cap so subsequent `memory.grow` calls are checked
166
- /// against `memory_limit`. The cap is dormant by default — the
167
- /// module's declared initial memory is allocated during
168
- /// `Linker::instantiate` and SPEC.md E-20 scopes the trap to
169
- /// `memory.grow` (not the instantiation-time initial allocation).
170
- /// [`crate::wasm::Instance::run`] calls this right before
171
- /// `__kobako_run`.
172
- pub(super) fn activate(&mut self) {
197
+ /// Arm the cap so subsequent `memory.grow` calls are charged
198
+ /// against `max_memory` starting from `baseline` bytes. Called via
199
+ /// [`HostState::arm_memory_cap`] at the top of every invocation;
200
+ /// the cap is dormant by default — the module's declared initial
201
+ /// memory is allocated during `Linker::instantiate` and the
202
+ /// per-invocation budget excludes anything that existed before
203
+ /// arming (docs/behavior.md B-01 Notes, E-20).
204
+ fn activate(&mut self, baseline: usize) {
205
+ self.baseline = baseline;
173
206
  self.cap_active = true;
174
207
  }
175
208
 
@@ -177,7 +210,7 @@ impl KobakoLimiter {
177
210
  /// OUTCOME_BUFFER, which can grow guest memory transiently) is
178
211
  /// not attributed to the user script. Paired with
179
212
  /// [`KobakoLimiter::activate`].
180
- pub(super) fn deactivate(&mut self) {
213
+ fn deactivate(&mut self) {
181
214
  self.cap_active = false;
182
215
  }
183
216
  }
@@ -193,7 +226,8 @@ impl ResourceLimiter for KobakoLimiter {
193
226
  return Ok(true);
194
227
  }
195
228
  if let Some(limit) = self.max_memory {
196
- if desired > limit {
229
+ let delta = desired.saturating_sub(self.baseline);
230
+ if delta > limit {
197
231
  return Err(wasmtime::Error::new(MemoryLimitTrap { desired, limit }));
198
232
  }
199
233
  }
@@ -211,7 +245,7 @@ impl ResourceLimiter for KobakoLimiter {
211
245
  }
212
246
 
213
247
  /// Marker error returned from [`KobakoLimiter::memory_growing`] on
214
- /// SPEC.md E-20. Downcast from the wasmtime trap error to surface as
248
+ /// docs/behavior.md E-20. Downcast from the wasmtime trap error to surface as
215
249
  /// `Kobako::Wasm::MemoryLimitError` on the Ruby side. Callers use the
216
250
  /// `Display` impl below — no field is read directly — so the inner
217
251
  /// state stays private.
@@ -221,6 +255,17 @@ pub(crate) struct MemoryLimitTrap {
221
255
  limit: usize,
222
256
  }
223
257
 
258
+ impl MemoryLimitTrap {
259
+ /// Construct a trap with the given +desired+ / +limit+ pair. Used
260
+ /// internally by [`KobakoLimiter::memory_growing`] in production and
261
+ /// by the sibling-module +classify_trap+ unit tests to materialise
262
+ /// a representative error for downcast routing.
263
+ #[cfg(test)]
264
+ pub(super) fn new(desired: usize, limit: usize) -> Self {
265
+ Self { desired, limit }
266
+ }
267
+ }
268
+
224
269
  impl std::fmt::Display for MemoryLimitTrap {
225
270
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226
271
  write!(
@@ -233,9 +278,9 @@ impl std::fmt::Display for MemoryLimitTrap {
233
278
 
234
279
  impl std::error::Error for MemoryLimitTrap {}
235
280
 
236
- /// Marker error returned from the epoch-deadline callback on SPEC.md
237
- /// E-19. Downcast from the wasmtime trap error to surface as
238
- /// `Kobako::Wasm::TimeoutError` on the Ruby side.
281
+ /// Marker error returned from the epoch-deadline callback on
282
+ /// docs/behavior.md E-19. Downcast from the wasmtime trap error to
283
+ /// surface as `Kobako::Wasm::TimeoutError` on the Ruby side.
239
284
  #[derive(Debug)]
240
285
  pub(crate) struct TimeoutTrap;
241
286
 
@@ -279,8 +324,100 @@ impl StoreCell {
279
324
  }
280
325
  }
281
326
 
282
- // SAFETY: Ruby's GVL serialises access to magnus-wrapped objects on a single
283
- // OS thread at a time. `wasmtime::Store` is `Send` (verified upstream); the
284
- // `RefCell`-mediated mutation is therefore safe under the GVL invariant.
327
+ // SAFETY: magnus requires `Send + Sync` on `#[magnus::wrap]` types. Both
328
+ // claims hold under the GVL invariant:
329
+ //
330
+ // * Send — `wasmtime::Store<HostState>` is itself `Send` (verified
331
+ // upstream by wasmtime; see `wasmtime::Store`'s trait impls).
332
+ // `RefCell<T>: Send` whenever `T: Send`. The remaining stored state
333
+ // (`HostState`) holds `Opaque<Value>` for the Ruby Server handle —
334
+ // `Opaque<Value>` is documented as `Send` by magnus precisely so
335
+ // wrapped objects can satisfy this bound.
336
+ //
337
+ // * Sync — `RefCell` is *not* `Sync` in the general Rust sense
338
+ // (concurrent `borrow_mut` is UB). We assert `Sync` here because the
339
+ // GVL serialises every call into Ruby C and every entry into magnus-
340
+ // wrapped methods onto a single OS thread at a time: by the time the
341
+ // `Sync` bound matters, magnus has already established that only one
342
+ // thread can be inside the wrapper. Cross-thread mutation cannot
343
+ // occur. If a future magnus release adopts a thread model that
344
+ // permits concurrent access to wrapped objects, this assertion would
345
+ // have to revert and `StoreCell` would need to switch to
346
+ // `Mutex<wasmtime::Store<…>>` — but as of magnus 0.8 the contract
347
+ // holds.
285
348
  unsafe impl Send for StoreCell {}
286
349
  unsafe impl Sync for StoreCell {}
350
+
351
+ #[cfg(test)]
352
+ mod tests {
353
+ //! Unit tests for [`KobakoLimiter`] — the per-invocation memory
354
+ //! delta cap. The Ruby-facing E2E suite exercises the full path
355
+ //! through wasmtime; these tests pin the pure delta arithmetic so
356
+ //! a regression that breaks the baseline accounting (e.g. dropping
357
+ //! the `baseline` subtraction, or letting `activate` carry stale
358
+ //! state across invocations) is caught without spinning up a
359
+ //! Store.
360
+ use super::{KobakoLimiter, MemoryLimitTrap};
361
+ use wasmtime::ResourceLimiter;
362
+
363
+ fn assert_growing(limiter: &mut KobakoLimiter, desired: usize) {
364
+ assert!(
365
+ limiter.memory_growing(0, desired, None).unwrap(),
366
+ "expected memory_growing({desired}) to allow growth"
367
+ );
368
+ }
369
+
370
+ fn assert_trapping(limiter: &mut KobakoLimiter, desired: usize) {
371
+ let err = limiter
372
+ .memory_growing(0, desired, None)
373
+ .expect_err("expected memory_growing to trap");
374
+ assert!(
375
+ err.downcast_ref::<MemoryLimitTrap>().is_some(),
376
+ "expected MemoryLimitTrap, got {err:?}"
377
+ );
378
+ }
379
+
380
+ #[test]
381
+ fn dormant_limiter_allows_any_growth() {
382
+ let mut limiter = KobakoLimiter::new(Some(1 << 20));
383
+ // Without `activate`, the cap is dormant — the module's
384
+ // declared initial allocation must pass through unconditionally.
385
+ assert_growing(&mut limiter, 100 << 20);
386
+ }
387
+
388
+ #[test]
389
+ fn delta_below_cap_passes_after_activate() {
390
+ let mut limiter = KobakoLimiter::new(Some(1 << 20));
391
+ limiter.activate(2 << 20);
392
+ // baseline=2 MiB, desired=2.5 MiB → delta=0.5 MiB ≤ 1 MiB cap.
393
+ assert_growing(&mut limiter, (2 << 20) + (1 << 19));
394
+ }
395
+
396
+ #[test]
397
+ fn delta_past_cap_traps_with_memory_limit_trap() {
398
+ let mut limiter = KobakoLimiter::new(Some(1 << 20));
399
+ limiter.activate(2 << 20);
400
+ // baseline=2 MiB, desired=4 MiB → delta=2 MiB > 1 MiB cap.
401
+ assert_trapping(&mut limiter, 4 << 20);
402
+ }
403
+
404
+ #[test]
405
+ fn activate_resets_baseline_on_each_invocation() {
406
+ let mut limiter = KobakoLimiter::new(Some(1 << 20));
407
+ limiter.activate(2 << 20);
408
+ assert_growing(&mut limiter, (2 << 20) + (1 << 20));
409
+ // Second invocation: linear memory has grown to 3 MiB. Re-arming
410
+ // must re-anchor the baseline so the next 1 MiB of growth fits
411
+ // the per-invocation budget rather than being charged against
412
+ // the prior invocation's residue.
413
+ limiter.activate(3 << 20);
414
+ assert_growing(&mut limiter, (3 << 20) + (1 << 20));
415
+ }
416
+
417
+ #[test]
418
+ fn disabled_cap_ignores_delta_size() {
419
+ let mut limiter = KobakoLimiter::new(None);
420
+ limiter.activate(0);
421
+ assert_growing(&mut limiter, 100 << 20);
422
+ }
423
+ }