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.
- checksums.yaml +4 -4
- data/Cargo.lock +1 -1
- data/README.md +123 -57
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +2 -2
- data/ext/kobako/src/wasm/cache.rs +3 -3
- data/ext/kobako/src/wasm/dispatch.rs +87 -36
- data/ext/kobako/src/wasm/host_state.rs +189 -52
- data/ext/kobako/src/wasm/instance.rs +367 -152
- data/ext/kobako/src/wasm.rs +19 -5
- data/lib/kobako/capture.rb +12 -10
- data/lib/kobako/codec/decoder.rb +3 -2
- data/lib/kobako/codec/encoder.rb +1 -1
- data/lib/kobako/codec/error.rb +3 -2
- data/lib/kobako/codec/factory.rb +11 -7
- data/lib/kobako/codec/utils.rb +3 -2
- data/lib/kobako/codec.rb +2 -1
- data/lib/kobako/errors.rb +22 -10
- data/lib/kobako/invocation.rb +112 -0
- data/lib/kobako/outcome/panic.rb +2 -2
- data/lib/kobako/outcome.rb +20 -13
- data/lib/kobako/rpc/dispatcher.rb +9 -9
- data/lib/kobako/rpc/envelope.rb +3 -3
- data/lib/kobako/rpc/fault.rb +3 -2
- data/lib/kobako/rpc/handle.rb +3 -2
- data/lib/kobako/rpc/handle_table.rb +7 -7
- data/lib/kobako/rpc/namespace.rb +3 -3
- data/lib/kobako/rpc/server.rb +14 -12
- data/lib/kobako/sandbox.rb +147 -125
- data/lib/kobako/sandbox_options.rb +73 -0
- data/lib/kobako/snippet/binary.rb +30 -0
- data/lib/kobako/snippet/source.rb +28 -0
- data/lib/kobako/snippet/table.rb +174 -0
- data/lib/kobako/snippet.rb +20 -0
- data/lib/kobako/version.rb +1 -1
- data/sig/kobako/errors.rbs +3 -0
- data/sig/kobako/invocation.rbs +23 -0
- data/sig/kobako/sandbox.rbs +17 -18
- data/sig/kobako/sandbox_options.rbs +32 -0
- data/sig/kobako/snippet/binary.rbs +12 -0
- data/sig/kobako/snippet/source.rbs +13 -0
- data/sig/kobako/snippet/table.rbs +36 -0
- data/sig/kobako/snippet.rbs +4 -0
- data/sig/kobako/wasm.rbs +3 -1
- 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` (
|
|
7
|
+
//! every `#run` (docs/behavior.md B-03 / B-04).
|
|
8
8
|
//!
|
|
9
|
-
//! The state also carries the per-
|
|
10
|
-
//! E-19) and the linear-memory
|
|
11
|
-
//! E-20). Both are
|
|
12
|
-
//! `
|
|
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 (
|
|
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::
|
|
58
|
-
///
|
|
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
|
|
104
|
-
/// so reaching this branch with
|
|
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
|
-
|
|
108
|
-
|
|
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` (
|
|
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`].
|
|
125
|
-
/// wasmtime [`ResourceLimiter`] callback
|
|
126
|
-
///
|
|
127
|
-
///
|
|
128
|
-
///
|
|
129
|
-
///
|
|
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`
|
|
136
|
-
/// B-01 / E-20
|
|
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
|
|
139
|
-
///
|
|
140
|
-
///
|
|
141
|
-
///
|
|
142
|
-
///
|
|
143
|
-
///
|
|
144
|
-
/// `
|
|
145
|
-
///
|
|
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
|
|
148
|
-
/// limiter returns [`MemoryLimitTrap`] from
|
|
149
|
-
/// turns that into the trap surfaced to the
|
|
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
|
|
166
|
-
/// against `
|
|
167
|
-
///
|
|
168
|
-
///
|
|
169
|
-
///
|
|
170
|
-
///
|
|
171
|
-
///
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
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
|
|
237
|
-
/// E-19. Downcast from the wasmtime trap error to
|
|
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:
|
|
283
|
-
//
|
|
284
|
-
//
|
|
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
|
+
}
|