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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +205 -59
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +2 -2
  6. data/ext/kobako/src/wasm/cache.rs +15 -7
  7. data/ext/kobako/src/wasm/dispatch.rs +88 -36
  8. data/ext/kobako/src/wasm/host_state.rs +298 -55
  9. data/ext/kobako/src/wasm/instance.rs +477 -160
  10. data/ext/kobako/src/wasm.rs +20 -5
  11. data/lib/kobako/capture.rb +12 -10
  12. data/lib/kobako/codec/decoder.rb +3 -4
  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 +24 -17
  16. data/lib/kobako/codec/utils.rb +105 -12
  17. data/lib/kobako/codec.rb +2 -1
  18. data/lib/kobako/errors.rb +22 -10
  19. data/lib/kobako/handle.rb +62 -0
  20. data/lib/kobako/handle_table.rb +119 -0
  21. data/lib/kobako/invocation.rb +143 -0
  22. data/lib/kobako/outcome/panic.rb +2 -2
  23. data/lib/kobako/outcome.rb +61 -24
  24. data/lib/kobako/rpc/dispatcher.rb +30 -28
  25. data/lib/kobako/rpc/envelope.rb +10 -10
  26. data/lib/kobako/rpc/fault.rb +4 -3
  27. data/lib/kobako/rpc/namespace.rb +3 -3
  28. data/lib/kobako/rpc/server.rb +23 -33
  29. data/lib/kobako/rpc/wire_error.rb +23 -0
  30. data/lib/kobako/sandbox.rb +211 -136
  31. data/lib/kobako/sandbox_options.rb +73 -0
  32. data/lib/kobako/snippet/binary.rb +30 -0
  33. data/lib/kobako/snippet/source.rb +28 -0
  34. data/lib/kobako/snippet/table.rb +174 -0
  35. data/lib/kobako/snippet.rb +20 -0
  36. data/lib/kobako/usage.rb +41 -0
  37. data/lib/kobako/version.rb +1 -1
  38. data/lib/kobako.rb +1 -0
  39. data/sig/kobako/codec/factory.rbs +1 -1
  40. data/sig/kobako/codec/utils.rbs +10 -0
  41. data/sig/kobako/errors.rbs +3 -0
  42. data/sig/kobako/handle.rbs +19 -0
  43. data/sig/kobako/handle_table.rbs +23 -0
  44. data/sig/kobako/invocation.rbs +25 -0
  45. data/sig/kobako/outcome.rbs +1 -1
  46. data/sig/kobako/rpc/dispatcher.rbs +7 -7
  47. data/sig/kobako/rpc/envelope.rbs +3 -3
  48. data/sig/kobako/rpc/server.rbs +1 -7
  49. data/sig/kobako/rpc/wire_error.rbs +6 -0
  50. data/sig/kobako/sandbox.rbs +22 -17
  51. data/sig/kobako/sandbox_options.rbs +32 -0
  52. data/sig/kobako/snippet/binary.rbs +12 -0
  53. data/sig/kobako/snippet/source.rbs +13 -0
  54. data/sig/kobako/snippet/table.rbs +36 -0
  55. data/sig/kobako/snippet.rbs +4 -0
  56. data/sig/kobako/usage.rbs +11 -0
  57. data/sig/kobako/wasm.rbs +5 -1
  58. metadata +21 -5
  59. data/lib/kobako/rpc/handle.rb +0 -38
  60. data/lib/kobako/rpc/handle_table.rb +0 -107
  61. data/sig/kobako/rpc/handle.rbs +0 -19
  62. 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 (SPEC.md B-12 / B-13):
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
- let req_bytes = match read_caller_memory(caller, req_ptr, req_len) {
33
- Some(b) => b,
34
- None => return 0,
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
- // No Server bound return 0 to signal a wire-layer fault; the guest
38
- // maps a 0 return to a trap. `Kobako::Sandbox` always installs a
39
- // Server before invoking the guest, so reaching this branch indicates
40
- // a misuse rather than a normal control path.
41
- let server = match caller.data().server() {
42
- Some(d) => d,
43
- None => return 0,
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
- let resp_bytes = match invoke_server(server, &req_bytes) {
47
- Ok(b) => b,
48
- Err(_) => return 0,
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).unwrap_or(0)
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 Implementation Standards
61
- // Architecture pins for the host gem — so `Ruby::get()` is always
62
- // available here. Panicking with `expect` localises the violation
63
- // rather than letting a nonsense error propagate.
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
- // SAFETY: the returned RString is held by the Ruby VM for the duration of
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
- fn write_response(caller: &mut Caller<'_, HostState>, bytes: &[u8]) -> Option<i64> {
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.typed::<i32, i32>(&*caller).ok()?,
79
- _ => return None,
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()).ok()?;
82
- let ptr = alloc.call(&mut *caller, len_i32).ok()?;
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 None;
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 None,
140
+ _ => return Err("Sandbox runtime does not export linear memory"),
90
141
  };
91
- mem.write(&mut *caller, ptr as usize, bytes).ok()?;
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
- Some(((ptr_u32 as i64) << 32) | (len_u32 as i64))
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` (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
- 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 (SPEC.md B-01, E-20).
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::run`] at the top
58
- /// of every guest invocation (SPEC.md B-03 / B-04).
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 `Instance::run`,
104
- /// so reaching this branch with `None` signals a host-side wiring bug.
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
- .as_mut()
108
- .expect("WASI context not initialised — call Instance#run before any WASI use")
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` (SPEC.md B-01).
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`]. 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.
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` cap from SPEC.md
136
- /// B-01 / E-20 on every guest `memory.grow`.
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 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.
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 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.
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 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) {
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
- pub(super) fn deactivate(&mut self) {
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 desired > limit {
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
- /// SPEC.md E-20. Downcast from the wasmtime trap error to surface as
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
- "guest memory.grow would exceed memory_limit: desired={} bytes, limit={} bytes",
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 SPEC.md
237
- /// E-19. Downcast from the wasmtime trap error to surface as
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, "guest exceeded the configured wall-clock timeout")
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: 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.
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
+ }