kobako 0.2.1 → 0.4.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 +205 -59
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +2 -2
- data/ext/kobako/src/wasm/cache.rs +15 -7
- data/ext/kobako/src/wasm/dispatch.rs +88 -36
- data/ext/kobako/src/wasm/host_state.rs +298 -55
- data/ext/kobako/src/wasm/instance.rs +477 -160
- data/ext/kobako/src/wasm.rs +20 -5
- data/lib/kobako/capture.rb +12 -10
- data/lib/kobako/codec/decoder.rb +3 -4
- data/lib/kobako/codec/encoder.rb +1 -1
- data/lib/kobako/codec/error.rb +3 -2
- data/lib/kobako/codec/factory.rb +24 -17
- data/lib/kobako/codec/utils.rb +105 -12
- data/lib/kobako/codec.rb +2 -1
- data/lib/kobako/errors.rb +22 -10
- data/lib/kobako/handle.rb +62 -0
- data/lib/kobako/handle_table.rb +119 -0
- data/lib/kobako/invocation.rb +143 -0
- data/lib/kobako/outcome/panic.rb +2 -2
- data/lib/kobako/outcome.rb +61 -24
- data/lib/kobako/rpc/dispatcher.rb +30 -28
- data/lib/kobako/rpc/envelope.rb +10 -10
- data/lib/kobako/rpc/fault.rb +4 -3
- data/lib/kobako/rpc/namespace.rb +3 -3
- data/lib/kobako/rpc/server.rb +23 -33
- data/lib/kobako/rpc/wire_error.rb +23 -0
- data/lib/kobako/sandbox.rb +211 -136
- 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/usage.rb +41 -0
- data/lib/kobako/version.rb +1 -1
- data/lib/kobako.rb +1 -0
- data/sig/kobako/codec/factory.rbs +1 -1
- data/sig/kobako/codec/utils.rbs +10 -0
- data/sig/kobako/errors.rbs +3 -0
- data/sig/kobako/handle.rbs +19 -0
- data/sig/kobako/handle_table.rbs +23 -0
- data/sig/kobako/invocation.rbs +25 -0
- data/sig/kobako/outcome.rbs +1 -1
- data/sig/kobako/rpc/dispatcher.rbs +7 -7
- data/sig/kobako/rpc/envelope.rbs +3 -3
- data/sig/kobako/rpc/server.rbs +1 -7
- data/sig/kobako/rpc/wire_error.rbs +6 -0
- data/sig/kobako/sandbox.rbs +22 -17
- 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/usage.rbs +11 -0
- data/sig/kobako/wasm.rbs +5 -1
- metadata +21 -5
- data/lib/kobako/rpc/handle.rb +0 -38
- data/lib/kobako/rpc/handle_table.rb +0 -107
- data/sig/kobako/rpc/handle.rbs +0 -19
- data/sig/kobako/rpc/handle_table.rbs +0 -25
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//! When the guest invokes the wasm import declared in
|
|
4
4
|
//! `wasm/kobako-wasm/src/abi.rs`, wasmtime calls back into the host
|
|
5
5
|
//! through the closure built in [`super::instance::Instance::build`].
|
|
6
|
-
//! That closure delegates here. The dispatcher (
|
|
6
|
+
//! That closure delegates here. The dispatcher (docs/behavior.md B-12 / B-13):
|
|
7
7
|
//!
|
|
8
8
|
//! 1. Reads the Request bytes from guest linear memory.
|
|
9
9
|
//! 2. Hands them to the Ruby-side `Kobako::RPC::Server` and recovers
|
|
@@ -19,6 +19,31 @@
|
|
|
19
19
|
//! return to a trap. Failures during normal dispatch surface as
|
|
20
20
|
//! Response.err envelopes from the Server itself — they never reach
|
|
21
21
|
//! this 0-return path.
|
|
22
|
+
//!
|
|
23
|
+
//! ## Why this module writes to `stderr`
|
|
24
|
+
//!
|
|
25
|
+
//! This file is the one place in `ext/` that deliberately prints
|
|
26
|
+
//! through `eprintln!`. The host normally surfaces faults by
|
|
27
|
+
//! raising a `MagnusError` back into Ruby; the dispatcher contract
|
|
28
|
+
//! is the exception — it must return a packed `i64` to the guest
|
|
29
|
+
//! and cannot raise, so a 0 return is the only signal the wasm side
|
|
30
|
+
//! receives. The guest collapses every 0 into the same trap, so the
|
|
31
|
+
//! Ruby host has no way to attribute the failure to a specific
|
|
32
|
+
//! step (missing `memory` export vs. no Server bound vs. Server
|
|
33
|
+
//! raised vs. `__kobako_alloc` returned 0 vs. `memory.write`
|
|
34
|
+
//! rejected).
|
|
35
|
+
//!
|
|
36
|
+
//! [`handle`] writes a single `[kobako-dispatch] <reason>` line to
|
|
37
|
+
//! `stderr` on each failure path so operators have a breadcrumb to
|
|
38
|
+
//! correlate the trap with the actual cause. The line is emitted in
|
|
39
|
+
//! both debug and release builds on purpose: dispatcher failures
|
|
40
|
+
//! are wire-layer faults rather than expected error paths
|
|
41
|
+
//! (`Kobako::Sandbox` always installs a Server, the Server is
|
|
42
|
+
//! contracted never to raise, etc.), so the "release-build noise"
|
|
43
|
+
//! cost is bounded — under normal operation the line is never
|
|
44
|
+
//! written. Operators that need to silence the channel can redirect
|
|
45
|
+
//! the host process's stderr, but the kobako convention is "ext
|
|
46
|
+
//! never logs" plus this single, named exception.
|
|
22
47
|
|
|
23
48
|
use magnus::value::{Opaque, ReprValue};
|
|
24
49
|
use magnus::{Error as MagnusError, RString, Ruby, Value};
|
|
@@ -28,27 +53,49 @@ use super::host_state::HostState;
|
|
|
28
53
|
|
|
29
54
|
/// Drive a single `__kobako_dispatch` invocation end-to-end. Entry point
|
|
30
55
|
/// from the wasmtime closure built in [`super::instance::Instance::build`].
|
|
56
|
+
///
|
|
57
|
+
/// Returns the packed `(ptr<<32)|len` u64 on success, 0 on any
|
|
58
|
+
/// wire-layer fault. Failure paths log a `[kobako-dispatch]` line to
|
|
59
|
+
/// `stderr` so operators have a breadcrumb when the guest sees a 0
|
|
60
|
+
/// return and traps; before this every failure was silent. The Server
|
|
61
|
+
/// itself is contracted never to raise (it folds Service exceptions
|
|
62
|
+
/// into Response.err envelopes), so reaching the failure path is
|
|
63
|
+
/// always a wiring bug or wire-layer fault rather than an expected
|
|
64
|
+
/// path.
|
|
31
65
|
pub(crate) fn handle(caller: &mut Caller<'_, HostState>, req_ptr: i32, req_len: i32) -> i64 {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
match try_handle(caller, req_ptr, req_len) {
|
|
67
|
+
Ok(packed) => packed,
|
|
68
|
+
Err(reason) => {
|
|
69
|
+
eprintln!("[kobako-dispatch] {}", reason);
|
|
70
|
+
0
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
36
74
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
/// Result-returning core of [`handle`]. Pulled out so each early
|
|
76
|
+
/// failure path carries a diagnostic string instead of an opaque 0.
|
|
77
|
+
fn try_handle(
|
|
78
|
+
caller: &mut Caller<'_, HostState>,
|
|
79
|
+
req_ptr: i32,
|
|
80
|
+
req_len: i32,
|
|
81
|
+
) -> Result<i64, &'static str> {
|
|
82
|
+
let req_bytes = read_caller_memory(caller, req_ptr, req_len).ok_or(
|
|
83
|
+
"Sandbox runtime does not export linear memory, or RPC request slice falls outside it",
|
|
84
|
+
)?;
|
|
45
85
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
86
|
+
// `Kobako::Sandbox` always installs an RPC server before invoking
|
|
87
|
+
// the runtime, so reaching this branch indicates a misuse rather
|
|
88
|
+
// than a normal control path.
|
|
89
|
+
let server = caller
|
|
90
|
+
.data()
|
|
91
|
+
.server()
|
|
92
|
+
.ok_or("RPC dispatched outside an active Sandbox#run — internal wiring bug")?;
|
|
93
|
+
|
|
94
|
+
let resp_bytes = invoke_server(server, &req_bytes).map_err(|_| {
|
|
95
|
+
"RPC server raised an exception instead of returning a fault — please report this as a kobako bug"
|
|
96
|
+
})?;
|
|
50
97
|
|
|
51
|
-
write_response(caller, &resp_bytes)
|
|
98
|
+
write_response(caller, &resp_bytes)
|
|
52
99
|
}
|
|
53
100
|
|
|
54
101
|
/// Call the Ruby Server's `#dispatch(request_bytes)` method and return
|
|
@@ -56,43 +103,48 @@ pub(crate) fn handle(caller: &mut Caller<'_, HostState>, req_ptr: i32, req_len:
|
|
|
56
103
|
/// failed (it is contracted never to raise — see
|
|
57
104
|
/// `Kobako::RPC::Server#dispatch`), which we treat as a wire-layer fault.
|
|
58
105
|
fn invoke_server(server: Opaque<Value>, req_bytes: &[u8]) -> Result<Vec<u8>, MagnusError> {
|
|
59
|
-
// The wasmtime callback runs on the same Ruby thread that called
|
|
60
|
-
// Sandbox#run — the invariant SPEC
|
|
61
|
-
// Architecture pins for the host gem — so
|
|
62
|
-
// available here. Panicking with `expect`
|
|
63
|
-
// rather than letting a nonsense error
|
|
106
|
+
// The wasmtime callback runs on the same Ruby thread that called the
|
|
107
|
+
// active Sandbox invocation (#eval or #run) — the invariant SPEC
|
|
108
|
+
// Implementation Standards Architecture pins for the host gem — so
|
|
109
|
+
// `Ruby::get()` is always available here. Panicking with `expect`
|
|
110
|
+
// localises the violation rather than letting a nonsense error
|
|
111
|
+
// propagate.
|
|
64
112
|
let ruby = Ruby::get().expect("Ruby handle unavailable in __kobako_dispatch");
|
|
65
113
|
let server_value: Value = ruby.get_inner(server);
|
|
66
114
|
let req_str = ruby.str_from_slice(req_bytes);
|
|
67
115
|
let resp: RString = server_value.funcall("dispatch", (req_str,))?;
|
|
68
|
-
|
|
69
|
-
// this scope; copying its bytes into a Vec is a defensive standard pattern.
|
|
70
|
-
let bytes = unsafe { resp.as_slice() }.to_vec();
|
|
71
|
-
Ok(bytes)
|
|
116
|
+
Ok(super::rstring_to_vec(resp))
|
|
72
117
|
}
|
|
73
118
|
|
|
74
119
|
/// Allocate a guest-side buffer through `__kobako_alloc` and copy the
|
|
75
120
|
/// response bytes into it. Returns the packed `(ptr<<32)|len` u64.
|
|
76
|
-
|
|
121
|
+
/// Each failure path carries a `&'static str` reason so the dispatcher
|
|
122
|
+
/// wrapper can surface a useful diagnostic rather than a silent 0.
|
|
123
|
+
fn write_response(caller: &mut Caller<'_, HostState>, bytes: &[u8]) -> Result<i64, &'static str> {
|
|
77
124
|
let alloc = match caller.get_export("__kobako_alloc") {
|
|
78
|
-
Some(Extern::Func(f)) => f
|
|
79
|
-
|
|
125
|
+
Some(Extern::Func(f)) => f
|
|
126
|
+
.typed::<i32, i32>(&*caller)
|
|
127
|
+
.map_err(|_| "Sandbox runtime's allocation hook has the wrong signature")?,
|
|
128
|
+
_ => return Err("Sandbox runtime is missing the allocation hook"),
|
|
80
129
|
};
|
|
81
|
-
let len_i32 = i32::try_from(bytes.len()).
|
|
82
|
-
let ptr = alloc
|
|
130
|
+
let len_i32 = i32::try_from(bytes.len()).map_err(|_| "RPC response exceeds 2 GiB")?;
|
|
131
|
+
let ptr = alloc
|
|
132
|
+
.call(&mut *caller, len_i32)
|
|
133
|
+
.map_err(|_| "Sandbox allocation trapped while preparing the RPC response")?;
|
|
83
134
|
if ptr == 0 {
|
|
84
|
-
return
|
|
135
|
+
return Err("Sandbox is out of memory while preparing the RPC response");
|
|
85
136
|
}
|
|
86
137
|
|
|
87
138
|
let mem = match caller.get_export("memory") {
|
|
88
139
|
Some(Extern::Memory(m)) => m,
|
|
89
|
-
_ => return
|
|
140
|
+
_ => return Err("Sandbox runtime does not export linear memory"),
|
|
90
141
|
};
|
|
91
|
-
mem.write(&mut *caller, ptr as usize, bytes)
|
|
142
|
+
mem.write(&mut *caller, ptr as usize, bytes)
|
|
143
|
+
.map_err(|_| "could not write the RPC response into Sandbox memory (range invalid)")?;
|
|
92
144
|
|
|
93
145
|
let ptr_u32 = ptr as u32;
|
|
94
146
|
let len_u32 = bytes.len() as u32;
|
|
95
|
-
|
|
147
|
+
Ok(((ptr_u32 as i64) << 32) | (len_u32 as i64))
|
|
96
148
|
}
|
|
97
149
|
|
|
98
150
|
/// Copy `[ptr, ptr+len)` out of the guest's linear memory as seen from
|
|
@@ -4,16 +4,20 @@
|
|
|
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
|
-
use std::time::Instant;
|
|
20
|
+
use std::time::{Duration, Instant};
|
|
17
21
|
|
|
18
22
|
use magnus::{value::Opaque, Value};
|
|
19
23
|
use wasmtime::{ResourceLimiter, Store as WtStore};
|
|
@@ -35,13 +39,15 @@ pub(super) struct HostState {
|
|
|
35
39
|
server: Option<Opaque<Value>>,
|
|
36
40
|
deadline: Option<Instant>,
|
|
37
41
|
limiter: KobakoLimiter,
|
|
42
|
+
wall_entry: Option<Instant>,
|
|
43
|
+
wall_time: Duration,
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
impl HostState {
|
|
41
47
|
/// Build a fresh per-Store host state. `memory_limit` carries the
|
|
42
48
|
/// `Sandbox#memory_limit` cap in bytes (or `None` to disable the cap);
|
|
43
49
|
/// it is read from the wasmtime [`ResourceLimiter`] callback every
|
|
44
|
-
/// time the guest grows linear memory (
|
|
50
|
+
/// time the guest grows linear memory (docs/behavior.md B-01, E-20).
|
|
45
51
|
pub(super) fn new(memory_limit: Option<usize>) -> Self {
|
|
46
52
|
Self {
|
|
47
53
|
wasi: None,
|
|
@@ -50,12 +56,15 @@ impl HostState {
|
|
|
50
56
|
server: None,
|
|
51
57
|
deadline: None,
|
|
52
58
|
limiter: KobakoLimiter::new(memory_limit),
|
|
59
|
+
wall_entry: None,
|
|
60
|
+
wall_time: Duration::ZERO,
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/// Install a freshly-built WASI context plus the matching stdout/stderr
|
|
57
|
-
/// pipe clones. Called from [`crate::wasm::Instance::
|
|
58
|
-
///
|
|
65
|
+
/// pipe clones. Called from [`crate::wasm::Instance::eval`] /
|
|
66
|
+
/// [`crate::wasm::Instance::run`] at the top of every guest
|
|
67
|
+
/// invocation (docs/behavior.md B-03 / B-04).
|
|
59
68
|
pub(super) fn install_wasi(
|
|
60
69
|
&mut self,
|
|
61
70
|
wasi: WasiP1Ctx,
|
|
@@ -100,17 +109,18 @@ impl HostState {
|
|
|
100
109
|
|
|
101
110
|
/// Mutable handle to the live WASI context. Panics if no context has
|
|
102
111
|
/// been installed yet — every call site is downstream of
|
|
103
|
-
/// [`HostState::install_wasi`] running at the top of
|
|
104
|
-
/// so reaching this branch with
|
|
112
|
+
/// [`HostState::install_wasi`] running at the top of
|
|
113
|
+
/// `Instance::eval` / `Instance::run`, so reaching this branch with
|
|
114
|
+
/// `None` signals a host-side wiring bug.
|
|
105
115
|
pub(super) fn wasi_mut(&mut self) -> &mut WasiP1Ctx {
|
|
106
|
-
self.wasi
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
self.wasi.as_mut().expect(
|
|
117
|
+
"WASI context not initialised — call Instance#eval / Instance#run before any WASI use",
|
|
118
|
+
)
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
/// Replace the per-run wall-clock deadline. `Some(at)` makes the
|
|
112
122
|
/// epoch-deadline callback trap once `Instant::now() >= at`; `None`
|
|
113
|
-
/// disables the cap. Called at the top of every `#run` (
|
|
123
|
+
/// disables the cap. Called at the top of every `#run` (docs/behavior.md B-01).
|
|
114
124
|
pub(super) fn set_deadline(&mut self, deadline: Option<Instant>) {
|
|
115
125
|
self.deadline = deadline;
|
|
116
126
|
}
|
|
@@ -121,65 +131,146 @@ impl HostState {
|
|
|
121
131
|
self.deadline
|
|
122
132
|
}
|
|
123
133
|
|
|
124
|
-
/// Mutable handle to the embedded [`KobakoLimiter`].
|
|
125
|
-
/// wasmtime [`ResourceLimiter`] callback
|
|
126
|
-
///
|
|
127
|
-
///
|
|
128
|
-
///
|
|
129
|
-
///
|
|
134
|
+
/// Mutable handle to the embedded [`KobakoLimiter`]. Required by
|
|
135
|
+
/// the wasmtime [`ResourceLimiter`] callback wiring in
|
|
136
|
+
/// [`crate::wasm::Instance::from_path`]
|
|
137
|
+
/// (`store.limiter(|state| state.limiter_mut())`); kept private to
|
|
138
|
+
/// the wasm submodule so the only public surface for arming the
|
|
139
|
+
/// cap goes through [`HostState::arm_memory_cap`] /
|
|
140
|
+
/// [`HostState::disarm_memory_cap`].
|
|
130
141
|
pub(super) fn limiter_mut(&mut self) -> &mut KobakoLimiter {
|
|
131
142
|
&mut self.limiter
|
|
132
143
|
}
|
|
144
|
+
|
|
145
|
+
/// Arm the docs/behavior.md E-20 memory cap for one guest run with
|
|
146
|
+
/// the current linear-memory size as the baseline. The limiter
|
|
147
|
+
/// charges only the `memory.grow` delta past `baseline` against
|
|
148
|
+
/// the cap, so the mruby image's initial allocation and the
|
|
149
|
+
/// high-water mark left by prior invocations do not consume the
|
|
150
|
+
/// budget. Paired with [`HostState::disarm_memory_cap`] around the
|
|
151
|
+
/// call to the corresponding `__kobako_*` export so post-run host
|
|
152
|
+
/// bookkeeping (e.g. fetching the OUTCOME_BUFFER) is not
|
|
153
|
+
/// attributed to the user script.
|
|
154
|
+
pub(super) fn arm_memory_cap(&mut self, baseline: usize) {
|
|
155
|
+
self.limiter.activate(baseline);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Disarm the docs/behavior.md E-20 memory cap. See
|
|
159
|
+
/// [`HostState::arm_memory_cap`].
|
|
160
|
+
pub(super) fn disarm_memory_cap(&mut self) {
|
|
161
|
+
self.limiter.deactivate();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Stamp the wall-clock entry instant for the docs/behavior.md
|
|
165
|
+
/// B-35 `wall_time` measurement. Called at the top of every
|
|
166
|
+
/// invocation immediately before the guest export call so the
|
|
167
|
+
/// bracket matches the `timeout` deadline accounting (B-01) and
|
|
168
|
+
/// excludes post-run host bookkeeping such as `OUTCOME_BUFFER`
|
|
169
|
+
/// decoding.
|
|
170
|
+
pub(super) fn start_wall_clock(&mut self) {
|
|
171
|
+
self.wall_entry = Some(Instant::now());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Close the docs/behavior.md B-35 `wall_time` measurement
|
|
175
|
+
/// started by [`HostState::start_wall_clock`]. Idempotent — a
|
|
176
|
+
/// stop with no matching start (e.g. if the guest export call
|
|
177
|
+
/// never executed because of a host-side allocation failure)
|
|
178
|
+
/// leaves the previously-recorded value untouched.
|
|
179
|
+
pub(super) fn stop_wall_clock(&mut self) {
|
|
180
|
+
if let Some(entry) = self.wall_entry.take() {
|
|
181
|
+
self.wall_time = entry.elapsed();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Return the wall-clock duration the most recent invocation
|
|
186
|
+
/// spent inside the guest export call (docs/behavior.md B-35).
|
|
187
|
+
/// Zero before the first invocation.
|
|
188
|
+
pub(super) fn wall_time(&self) -> Duration {
|
|
189
|
+
self.wall_time
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Return the docs/behavior.md B-35 `memory_peak` — the high-
|
|
193
|
+
/// water mark of the per-invocation `memory.grow` delta past the
|
|
194
|
+
/// linear-memory size captured at invocation entry. Zero before
|
|
195
|
+
/// the first invocation.
|
|
196
|
+
pub(super) fn memory_peak(&self) -> usize {
|
|
197
|
+
self.limiter.peak()
|
|
198
|
+
}
|
|
133
199
|
}
|
|
134
200
|
|
|
135
|
-
/// Resource limiter that enforces the `memory_limit`
|
|
136
|
-
/// B-01 / E-20
|
|
201
|
+
/// Resource limiter that enforces the per-invocation `memory_limit`
|
|
202
|
+
/// cap from docs/behavior.md B-01 / E-20.
|
|
137
203
|
///
|
|
138
|
-
/// `max_memory` is the byte cap (`None` disables
|
|
139
|
-
///
|
|
140
|
-
///
|
|
141
|
-
///
|
|
142
|
-
///
|
|
143
|
-
///
|
|
144
|
-
/// `
|
|
145
|
-
///
|
|
204
|
+
/// `max_memory` is the byte cap on per-invocation growth (`None` disables
|
|
205
|
+
/// the cap). `baseline` is the linear-memory size captured at invocation
|
|
206
|
+
/// entry by [`KobakoLimiter::activate`]; the limiter charges only the
|
|
207
|
+
/// `memory.grow` delta past `baseline` against `max_memory`, so the
|
|
208
|
+
/// mruby image's initial allocation and any high-water mark left by
|
|
209
|
+
/// prior invocations on the same Sandbox do not consume the budget.
|
|
210
|
+
/// `cap_active` gates whether the cap is enforced — wasmtime's
|
|
211
|
+
/// `ResourceLimiter` also fires for the module's declared initial
|
|
212
|
+
/// 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
|
|
215
|
+
/// `false`, the limiter always allows growth.
|
|
146
216
|
///
|
|
147
|
-
/// When `memory.grow` would push
|
|
148
|
-
/// limiter returns [`MemoryLimitTrap`] from
|
|
149
|
-
/// turns that into the trap surfaced to the
|
|
150
|
-
/// failure.
|
|
217
|
+
/// When `memory.grow` would push the per-invocation delta past
|
|
218
|
+
/// `max_memory`, the limiter returns [`MemoryLimitTrap`] from
|
|
219
|
+
/// `memory_growing`; wasmtime turns that into the trap surfaced to the
|
|
220
|
+
/// host as a guest invocation failure.
|
|
151
221
|
#[derive(Debug, Clone, Copy)]
|
|
152
222
|
pub(super) struct KobakoLimiter {
|
|
153
223
|
max_memory: Option<usize>,
|
|
224
|
+
baseline: usize,
|
|
154
225
|
cap_active: bool,
|
|
226
|
+
peak: usize,
|
|
155
227
|
}
|
|
156
228
|
|
|
157
229
|
impl KobakoLimiter {
|
|
158
230
|
fn new(max_memory: Option<usize>) -> Self {
|
|
159
231
|
Self {
|
|
160
232
|
max_memory,
|
|
233
|
+
baseline: 0,
|
|
161
234
|
cap_active: false,
|
|
235
|
+
peak: 0,
|
|
162
236
|
}
|
|
163
237
|
}
|
|
164
238
|
|
|
165
|
-
/// Arm the cap so subsequent `memory.grow` calls are
|
|
166
|
-
/// against `
|
|
167
|
-
///
|
|
168
|
-
///
|
|
169
|
-
///
|
|
170
|
-
///
|
|
171
|
-
///
|
|
172
|
-
|
|
239
|
+
/// Arm the cap so subsequent `memory.grow` calls are charged
|
|
240
|
+
/// against `max_memory` starting from `baseline` bytes. Called via
|
|
241
|
+
/// [`HostState::arm_memory_cap`] at the top of every invocation;
|
|
242
|
+
/// the cap is dormant by default — the module's declared initial
|
|
243
|
+
/// memory is allocated during `Linker::instantiate` and the
|
|
244
|
+
/// per-invocation budget excludes anything that existed before
|
|
245
|
+
/// arming (docs/behavior.md B-01 Notes, E-20). Also clears the
|
|
246
|
+
/// per-invocation [`KobakoLimiter::peak`] high-water so the
|
|
247
|
+
/// docs/behavior.md B-35 `memory_peak` accounting restarts from
|
|
248
|
+
/// zero for the new invocation.
|
|
249
|
+
fn activate(&mut self, baseline: usize) {
|
|
250
|
+
self.baseline = baseline;
|
|
173
251
|
self.cap_active = true;
|
|
252
|
+
self.peak = 0;
|
|
174
253
|
}
|
|
175
254
|
|
|
176
255
|
/// Disarm the cap so post-run host bookkeeping (e.g. fetching the
|
|
177
256
|
/// OUTCOME_BUFFER, which can grow guest memory transiently) is
|
|
178
257
|
/// not attributed to the user script. Paired with
|
|
179
258
|
/// [`KobakoLimiter::activate`].
|
|
180
|
-
|
|
259
|
+
fn deactivate(&mut self) {
|
|
181
260
|
self.cap_active = false;
|
|
182
261
|
}
|
|
262
|
+
|
|
263
|
+
/// Return the high-water mark of the per-invocation
|
|
264
|
+
/// `memory.grow` delta past `baseline` observed since the last
|
|
265
|
+
/// [`KobakoLimiter::activate`]. Read after the guest export
|
|
266
|
+
/// returns to populate `Kobako::Usage#memory_peak`
|
|
267
|
+
/// (docs/behavior.md B-35). Pinned to the last accepted grow —
|
|
268
|
+
/// rejected `desired` values that trip the docs/behavior.md E-20
|
|
269
|
+
/// cap never update the peak, so the reported value never exceeds
|
|
270
|
+
/// `memory_limit`.
|
|
271
|
+
pub(super) fn peak(&self) -> usize {
|
|
272
|
+
self.peak
|
|
273
|
+
}
|
|
183
274
|
}
|
|
184
275
|
|
|
185
276
|
impl ResourceLimiter for KobakoLimiter {
|
|
@@ -192,11 +283,15 @@ impl ResourceLimiter for KobakoLimiter {
|
|
|
192
283
|
if !self.cap_active {
|
|
193
284
|
return Ok(true);
|
|
194
285
|
}
|
|
286
|
+
let delta = desired.saturating_sub(self.baseline);
|
|
195
287
|
if let Some(limit) = self.max_memory {
|
|
196
|
-
if
|
|
288
|
+
if delta > limit {
|
|
197
289
|
return Err(wasmtime::Error::new(MemoryLimitTrap { desired, limit }));
|
|
198
290
|
}
|
|
199
291
|
}
|
|
292
|
+
if delta > self.peak {
|
|
293
|
+
self.peak = delta;
|
|
294
|
+
}
|
|
200
295
|
Ok(true)
|
|
201
296
|
}
|
|
202
297
|
|
|
@@ -211,7 +306,7 @@ impl ResourceLimiter for KobakoLimiter {
|
|
|
211
306
|
}
|
|
212
307
|
|
|
213
308
|
/// Marker error returned from [`KobakoLimiter::memory_growing`] on
|
|
214
|
-
///
|
|
309
|
+
/// docs/behavior.md E-20. Downcast from the wasmtime trap error to surface as
|
|
215
310
|
/// `Kobako::Wasm::MemoryLimitError` on the Ruby side. Callers use the
|
|
216
311
|
/// `Display` impl below — no field is read directly — so the inner
|
|
217
312
|
/// state stays private.
|
|
@@ -221,11 +316,23 @@ pub(crate) struct MemoryLimitTrap {
|
|
|
221
316
|
limit: usize,
|
|
222
317
|
}
|
|
223
318
|
|
|
319
|
+
impl MemoryLimitTrap {
|
|
320
|
+
/// Construct a trap with the given +desired+ / +limit+ pair. Used
|
|
321
|
+
/// internally by [`KobakoLimiter::memory_growing`] in production and
|
|
322
|
+
/// by the sibling-module +classify_trap+ unit tests to materialise
|
|
323
|
+
/// a representative error for downcast routing.
|
|
324
|
+
#[cfg(test)]
|
|
325
|
+
pub(super) fn new(desired: usize, limit: usize) -> Self {
|
|
326
|
+
Self { desired, limit }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
224
330
|
impl std::fmt::Display for MemoryLimitTrap {
|
|
225
331
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
226
332
|
write!(
|
|
227
333
|
f,
|
|
228
|
-
"
|
|
334
|
+
"linear memory growth exceeded memory_limit: \
|
|
335
|
+
desired={} bytes, limit={} bytes",
|
|
229
336
|
self.desired, self.limit
|
|
230
337
|
)
|
|
231
338
|
}
|
|
@@ -233,15 +340,15 @@ impl std::fmt::Display for MemoryLimitTrap {
|
|
|
233
340
|
|
|
234
341
|
impl std::error::Error for MemoryLimitTrap {}
|
|
235
342
|
|
|
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.
|
|
343
|
+
/// Marker error returned from the epoch-deadline callback on
|
|
344
|
+
/// docs/behavior.md E-19. Downcast from the wasmtime trap error to
|
|
345
|
+
/// surface as `Kobako::Wasm::TimeoutError` on the Ruby side.
|
|
239
346
|
#[derive(Debug)]
|
|
240
347
|
pub(crate) struct TimeoutTrap;
|
|
241
348
|
|
|
242
349
|
impl std::fmt::Display for TimeoutTrap {
|
|
243
350
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
244
|
-
write!(f, "
|
|
351
|
+
write!(f, "wall-clock deadline exceeded")
|
|
245
352
|
}
|
|
246
353
|
}
|
|
247
354
|
|
|
@@ -279,8 +386,144 @@ impl StoreCell {
|
|
|
279
386
|
}
|
|
280
387
|
}
|
|
281
388
|
|
|
282
|
-
// SAFETY:
|
|
283
|
-
//
|
|
284
|
-
//
|
|
389
|
+
// SAFETY: magnus requires `Send + Sync` on `#[magnus::wrap]` types. Both
|
|
390
|
+
// claims hold under the GVL invariant:
|
|
391
|
+
//
|
|
392
|
+
// * Send — `wasmtime::Store<HostState>` is itself `Send` (verified
|
|
393
|
+
// upstream by wasmtime; see `wasmtime::Store`'s trait impls).
|
|
394
|
+
// `RefCell<T>: Send` whenever `T: Send`. The remaining stored state
|
|
395
|
+
// (`HostState`) holds `Opaque<Value>` for the Ruby Server handle —
|
|
396
|
+
// `Opaque<Value>` is documented as `Send` by magnus precisely so
|
|
397
|
+
// wrapped objects can satisfy this bound.
|
|
398
|
+
//
|
|
399
|
+
// * Sync — `RefCell` is *not* `Sync` in the general Rust sense
|
|
400
|
+
// (concurrent `borrow_mut` is UB). We assert `Sync` here because the
|
|
401
|
+
// GVL serialises every call into Ruby C and every entry into magnus-
|
|
402
|
+
// wrapped methods onto a single OS thread at a time: by the time the
|
|
403
|
+
// `Sync` bound matters, magnus has already established that only one
|
|
404
|
+
// thread can be inside the wrapper. Cross-thread mutation cannot
|
|
405
|
+
// occur. If a future magnus release adopts a thread model that
|
|
406
|
+
// permits concurrent access to wrapped objects, this assertion would
|
|
407
|
+
// have to revert and `StoreCell` would need to switch to
|
|
408
|
+
// `Mutex<wasmtime::Store<…>>` — but as of magnus 0.8 the contract
|
|
409
|
+
// holds.
|
|
285
410
|
unsafe impl Send for StoreCell {}
|
|
286
411
|
unsafe impl Sync for StoreCell {}
|
|
412
|
+
|
|
413
|
+
#[cfg(test)]
|
|
414
|
+
mod tests {
|
|
415
|
+
//! Unit tests for [`KobakoLimiter`] — the per-invocation memory
|
|
416
|
+
//! delta cap. The Ruby-facing E2E suite exercises the full path
|
|
417
|
+
//! through wasmtime; these tests pin the pure delta arithmetic so
|
|
418
|
+
//! a regression that breaks the baseline accounting (e.g. dropping
|
|
419
|
+
//! the `baseline` subtraction, or letting `activate` carry stale
|
|
420
|
+
//! state across invocations) is caught without spinning up a
|
|
421
|
+
//! Store.
|
|
422
|
+
use super::{KobakoLimiter, MemoryLimitTrap};
|
|
423
|
+
use wasmtime::ResourceLimiter;
|
|
424
|
+
|
|
425
|
+
fn assert_growing(limiter: &mut KobakoLimiter, desired: usize) {
|
|
426
|
+
assert!(
|
|
427
|
+
limiter.memory_growing(0, desired, None).unwrap(),
|
|
428
|
+
"expected memory_growing({desired}) to allow growth"
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
fn assert_trapping(limiter: &mut KobakoLimiter, desired: usize) {
|
|
433
|
+
let err = limiter
|
|
434
|
+
.memory_growing(0, desired, None)
|
|
435
|
+
.expect_err("expected memory_growing to trap");
|
|
436
|
+
assert!(
|
|
437
|
+
err.downcast_ref::<MemoryLimitTrap>().is_some(),
|
|
438
|
+
"expected MemoryLimitTrap, got {err:?}"
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
#[test]
|
|
443
|
+
fn dormant_limiter_allows_any_growth() {
|
|
444
|
+
let mut limiter = KobakoLimiter::new(Some(1 << 20));
|
|
445
|
+
// Without `activate`, the cap is dormant — the module's
|
|
446
|
+
// declared initial allocation must pass through unconditionally.
|
|
447
|
+
assert_growing(&mut limiter, 100 << 20);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#[test]
|
|
451
|
+
fn delta_below_cap_passes_after_activate() {
|
|
452
|
+
let mut limiter = KobakoLimiter::new(Some(1 << 20));
|
|
453
|
+
limiter.activate(2 << 20);
|
|
454
|
+
// baseline=2 MiB, desired=2.5 MiB → delta=0.5 MiB ≤ 1 MiB cap.
|
|
455
|
+
assert_growing(&mut limiter, (2 << 20) + (1 << 19));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#[test]
|
|
459
|
+
fn delta_past_cap_traps_with_memory_limit_trap() {
|
|
460
|
+
let mut limiter = KobakoLimiter::new(Some(1 << 20));
|
|
461
|
+
limiter.activate(2 << 20);
|
|
462
|
+
// baseline=2 MiB, desired=4 MiB → delta=2 MiB > 1 MiB cap.
|
|
463
|
+
assert_trapping(&mut limiter, 4 << 20);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#[test]
|
|
467
|
+
fn activate_resets_baseline_on_each_invocation() {
|
|
468
|
+
let mut limiter = KobakoLimiter::new(Some(1 << 20));
|
|
469
|
+
limiter.activate(2 << 20);
|
|
470
|
+
assert_growing(&mut limiter, (2 << 20) + (1 << 20));
|
|
471
|
+
// Second invocation: linear memory has grown to 3 MiB. Re-arming
|
|
472
|
+
// must re-anchor the baseline so the next 1 MiB of growth fits
|
|
473
|
+
// the per-invocation budget rather than being charged against
|
|
474
|
+
// the prior invocation's residue.
|
|
475
|
+
limiter.activate(3 << 20);
|
|
476
|
+
assert_growing(&mut limiter, (3 << 20) + (1 << 20));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#[test]
|
|
480
|
+
fn disabled_cap_ignores_delta_size() {
|
|
481
|
+
let mut limiter = KobakoLimiter::new(None);
|
|
482
|
+
limiter.activate(0);
|
|
483
|
+
assert_growing(&mut limiter, 100 << 20);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
#[test]
|
|
487
|
+
fn peak_starts_at_zero_before_any_grow() {
|
|
488
|
+
let limiter = KobakoLimiter::new(Some(1 << 20));
|
|
489
|
+
assert_eq!(limiter.peak(), 0);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#[test]
|
|
493
|
+
fn peak_tracks_high_water_of_delta_past_baseline() {
|
|
494
|
+
let mut limiter = KobakoLimiter::new(Some(1 << 20));
|
|
495
|
+
limiter.activate(2 << 20);
|
|
496
|
+
assert_growing(&mut limiter, (2 << 20) + (1 << 18)); // delta=256 KiB
|
|
497
|
+
assert_growing(&mut limiter, (2 << 20) + (1 << 19)); // delta=512 KiB (new peak)
|
|
498
|
+
assert_growing(&mut limiter, (2 << 20) + (1 << 17)); // delta=128 KiB (below peak)
|
|
499
|
+
assert_eq!(limiter.peak(), 1 << 19);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#[test]
|
|
503
|
+
fn trap_does_not_update_peak() {
|
|
504
|
+
let mut limiter = KobakoLimiter::new(Some(1 << 20));
|
|
505
|
+
limiter.activate(2 << 20);
|
|
506
|
+
assert_growing(&mut limiter, (2 << 20) + (1 << 19)); // delta=512 KiB
|
|
507
|
+
assert_trapping(&mut limiter, (2 << 20) + (2 << 20)); // would be 2 MiB > 1 MiB cap
|
|
508
|
+
// Peak reflects the last accepted grow, not the rejected desired.
|
|
509
|
+
assert_eq!(limiter.peak(), 1 << 19);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#[test]
|
|
513
|
+
fn activate_resets_peak_for_new_invocation() {
|
|
514
|
+
let mut limiter = KobakoLimiter::new(Some(1 << 20));
|
|
515
|
+
limiter.activate(2 << 20);
|
|
516
|
+
assert_growing(&mut limiter, (2 << 20) + (1 << 19));
|
|
517
|
+
assert_eq!(limiter.peak(), 1 << 19);
|
|
518
|
+
limiter.activate(3 << 20);
|
|
519
|
+
assert_eq!(limiter.peak(), 0);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#[test]
|
|
523
|
+
fn disabled_cap_still_tracks_peak() {
|
|
524
|
+
let mut limiter = KobakoLimiter::new(None);
|
|
525
|
+
limiter.activate(1 << 20);
|
|
526
|
+
assert_growing(&mut limiter, (1 << 20) + (4 << 20));
|
|
527
|
+
assert_eq!(limiter.peak(), 4 << 20);
|
|
528
|
+
}
|
|
529
|
+
}
|