kobako 0.3.0 → 0.5.0

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