kobako 0.3.0 → 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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +85 -5
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +1 -1
  6. data/ext/kobako/src/wasm/cache.rs +12 -4
  7. data/ext/kobako/src/wasm/dispatch.rs +15 -14
  8. data/ext/kobako/src/wasm/host_state.rs +111 -5
  9. data/ext/kobako/src/wasm/instance.rs +135 -33
  10. data/ext/kobako/src/wasm.rs +1 -0
  11. data/lib/kobako/codec/decoder.rb +0 -2
  12. data/lib/kobako/codec/factory.rb +13 -10
  13. data/lib/kobako/codec/utils.rb +105 -13
  14. data/lib/kobako/handle.rb +62 -0
  15. data/lib/kobako/handle_table.rb +119 -0
  16. data/lib/kobako/invocation.rb +56 -25
  17. data/lib/kobako/outcome.rb +42 -12
  18. data/lib/kobako/rpc/dispatcher.rb +22 -20
  19. data/lib/kobako/rpc/envelope.rb +7 -7
  20. data/lib/kobako/rpc/fault.rb +1 -1
  21. data/lib/kobako/rpc/server.rb +12 -24
  22. data/lib/kobako/rpc/wire_error.rb +23 -0
  23. data/lib/kobako/sandbox.rb +77 -24
  24. data/lib/kobako/usage.rb +41 -0
  25. data/lib/kobako/version.rb +1 -1
  26. data/lib/kobako.rb +1 -0
  27. data/sig/kobako/codec/factory.rbs +1 -1
  28. data/sig/kobako/codec/utils.rbs +10 -0
  29. data/sig/kobako/handle.rbs +19 -0
  30. data/sig/kobako/handle_table.rbs +23 -0
  31. data/sig/kobako/invocation.rbs +3 -1
  32. data/sig/kobako/outcome.rbs +1 -1
  33. data/sig/kobako/rpc/dispatcher.rbs +7 -7
  34. data/sig/kobako/rpc/envelope.rbs +3 -3
  35. data/sig/kobako/rpc/server.rbs +1 -7
  36. data/sig/kobako/rpc/wire_error.rbs +6 -0
  37. data/sig/kobako/sandbox.rbs +7 -1
  38. data/sig/kobako/usage.rbs +11 -0
  39. data/sig/kobako/wasm.rbs +2 -0
  40. metadata +9 -5
  41. data/lib/kobako/rpc/handle.rb +0 -39
  42. data/lib/kobako/rpc/handle_table.rb +0 -107
  43. data/sig/kobako/rpc/handle.rbs +0 -19
  44. data/sig/kobako/rpc/handle_table.rbs +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ddc9be38e5ce7f23c176bcd7be8b8683cf58d308dfedb17e91fc5841308a5c8c
4
- data.tar.gz: 4a0de9e36b529010b50148ff9d904cafbe253b0c394a9a2118e6ef4d7c9e4029
3
+ metadata.gz: bb91cd11e954d6388b7d6c19be8b9fc77548fa1ea9d57b75f1afc7c0d450a36b
4
+ data.tar.gz: f84463e4b30e2ae5cb1e7d09a7c55345a419afd442613a1eb6b080682263587f
5
5
  SHA512:
6
- metadata.gz: ae0e599389ce723a1c257923a5b5b760ddf94a71698e5fa990f82587494ae8ca874d11c8c1fd5eedfac0d6e953f53cfb8603fb4da34989f8f92e39425bb99fa5
7
- data.tar.gz: 6cbdec819843c52c1be3d04a6694af51ae41121e159a4de12d1ebef0074d1626247c8c06db0e9f15da2deb19b4b1730c124aa027bc676ffc2b1fedf6cd68d3c7
6
+ metadata.gz: d622978cf22e2b30dbf8674275bbaaf39d0de68962709b40b67194d0521ca3d1e991e9a4e59853e634c111fc852d4719864c2990f2baaf35e1395efa3f67b63a
7
+ data.tar.gz: 3f828b5374841d0bcb8136a7c1aa078668c05c3673c794c50f86de9b1aee0bd915d090e022061b3e9636abb2dfb85a0b3bf1a7bbacff968635d9ed01c5f21edd
data/Cargo.lock CHANGED
@@ -864,7 +864,7 @@ dependencies = [
864
864
 
865
865
  [[package]]
866
866
  name = "kobako"
867
- version = "0.3.0"
867
+ version = "0.4.0"
868
868
  dependencies = [
869
869
  "magnus",
870
870
  "wasmtime",
data/README.md CHANGED
@@ -27,10 +27,11 @@ The host (`wasmtime`) runs a precompiled `kobako.wasm` guest containing mruby an
27
27
  | Per-invocation caps | Every invocation enforces a wall-clock `timeout` (default 60 s) and a per-invocation linear-memory `memory_limit` (default 1 MiB); exhaustion raises `Kobako::TimeoutError` / `Kobako::MemoryLimitError`. |
28
28
  | Capability injection via Services | Guest scripts can only call Ruby objects you explicitly `bind` under a two-level `Namespace::Member` path. |
29
29
  | Preloaded snippets | `Sandbox#preload` registers source or RITE bytecode for setup-once dispatch via `Sandbox#run(:Entrypoint, *args, **kwargs)`. |
30
- | Capability Handles | Services may return stateful host objects; the guest receives an opaque `Kobako::RPC::Handle` proxy it can use as the target of follow-up RPC calls, with no way to dereference it. |
30
+ | Capability Handles | Services may return stateful host objects; the guest receives an opaque `Kobako::Handle` proxy it can use as the target of follow-up RPC calls, with no way to dereference it. `Sandbox#run` also accepts non-wire-representable Ruby objects as args and auto-wraps them into Handles, so the guest can use any host object the script needs. |
31
31
  | Three-class error taxonomy | Every failure is exactly one of `TrapError`, `SandboxError`, or `ServiceError`, so you can route errors without inspecting messages. |
32
32
  | Per-invocation state reset | Handles issued during one invocation are invalidated before the next; Service bindings and preloaded snippets remain. |
33
33
  | Separated stdout / stderr capture | Guest writes to `$stdout` / `$stderr` are buffered per-channel (1 MiB default cap, configurable); overflow is clipped and reported by `#stdout_truncated?` / `#stderr_truncated?`. |
34
+ | Per-invocation usage readout | `Sandbox#usage` returns the most recent invocation's `wall_time` (Float seconds spent inside the wasm guest) and `memory_peak` (high-water `memory.grow` delta in bytes), populated on every outcome including `TrapError`, for budget diagnostics. |
34
35
  | Curated mruby stdlib | Core extensions plus `mruby-onig-regexp` for full Onigmo `Regexp` support; no mrbgem with I/O, network, or syscall access is bundled. |
35
36
 
36
37
  ## Requirements
@@ -122,6 +123,19 @@ The timeout deadline is absolute wall-clock from invocation entry and is checked
122
123
 
123
124
  The 1 MiB default targets lightweight dynamic RPC workloads — short scripts that orchestrate Service calls, return small structured values, or replace a tool-calling layer in an AI Agent's Code Mode dispatch. Bump `memory_limit` when scripts compose multi-hundred-KiB strings, hold large composite return values, or run computations that allocate substantial intermediate state. Because the cap resets every invocation, multi-call patterns on one Sandbox do not need a budget that covers their cumulative footprint — only the largest single invocation's working set.
124
125
 
126
+ To see how much of the cap an invocation actually consumed, read `Sandbox#usage` after the call. It returns a `Kobako::Usage` value object with `wall_time` (Float seconds the guest export call spent inside wasmtime, aligned with the `timeout` accounting) and `memory_peak` (Integer high-water `memory.grow` delta in bytes, aligned with the `memory_limit` accounting). The fields are populated on every outcome, including the `TrapError` branches, so you can read them after rescuing a trap to diagnose which budget the failing invocation chewed through.
127
+
128
+ ```ruby
129
+ sandbox = Kobako::Sandbox.new(timeout: 1.0, memory_limit: 4 * 1024 * 1024)
130
+
131
+ begin
132
+ sandbox.eval("'x' * 5_000_000")
133
+ rescue Kobako::MemoryLimitError
134
+ sandbox.usage.memory_peak # => the largest delta accepted before the trap
135
+ sandbox.usage.wall_time # => seconds spent before the cap fired
136
+ end
137
+ ```
138
+
125
139
  ## Capturing stdout and stderr
126
140
 
127
141
  Guest output is captured into per-invocation buffers and exposed independently from the return value. The buffers cover the full Ruby IO surface — `puts`, `print`, `printf`, `p`, `<<`, and writes through `$stdout` / `$stderr` — all routed through the host-captured WASI pipe.
@@ -183,7 +197,7 @@ end
183
197
 
184
198
  ## Capability Handles
185
199
 
186
- When a Service returns a stateful host object (anything beyond `nil` / Boolean / Integer / Float / String / Symbol / Array / Hash), the wire layer transparently allocates an opaque Handle. The guest receives a `Kobako::RPC::Handle` proxy it can use as the target of further RPC calls — but cannot dereference, forge from an integer, or smuggle across runs.
200
+ When a Service returns a stateful host object (anything beyond `nil` / Boolean / Integer / Float / String / Symbol / Array / Hash), the wire layer transparently allocates an opaque Handle. The guest receives a `Kobako::Handle` proxy it can use as the target of further RPC calls — but cannot dereference, forge from an integer, or smuggle across runs.
187
201
 
188
202
  ```ruby
189
203
  class Greeter
@@ -194,18 +208,69 @@ end
194
208
  sandbox.define(:Factory).bind(:Make, ->(name) { Greeter.new(name) })
195
209
 
196
210
  sandbox.eval(<<~RUBY)
197
- g = Factory::Make.call("Bob") # g is a Kobako::RPC::Handle proxy
211
+ g = Factory::Make.call("Bob") # g is a Kobako::Handle proxy
198
212
  g.greet # second RPC, routed to the Greeter
199
213
  RUBY
200
214
  # => "hi, Bob"
201
215
  ```
202
216
 
217
+ `Sandbox#run` accepts non-wire-representable host objects as args / kwargs values too: the host walks the argument tree, wraps every non-wire leaf through the same Handle path, and the guest sees a `Kobako::Handle` proxy in its place. This lets you pass framework objects (a Rack `env` Hash containing an `IO`-like body, an active record, an enumerator) into the entrypoint without first marshalling them into primitives.
218
+
219
+ ```ruby
220
+ require "stringio"
221
+
222
+ sandbox = Kobako::Sandbox.new
223
+ sandbox.preload(code: "Echo = ->(body) { body.read.upcase }", name: :Echo)
224
+
225
+ sandbox.run(:Echo, StringIO.new("hello world"))
226
+ # => "HELLO WORLD"
227
+ ```
228
+
203
229
  Handles are scoped to a single invocation — a Handle obtained in invocation N is invalid in invocation N+1, even on the same Sandbox.
204
230
 
205
231
  ## Setup-once, run-many
206
232
 
207
233
  A single Sandbox can serve many invocations. Service bindings and preloaded snippets persist; capability state (Handles, stdout, stderr) resets between invocations.
208
234
 
235
+ ```
236
+ ───────────── setup phase (mutable) ─────────────
237
+
238
+ sandbox = Kobako::Sandbox.new
239
+ sandbox.define(:KV).bind(:Lookup, ...)
240
+ sandbox.preload(code: ..., name: :Adder)
241
+ sandbox.preload(code: ..., name: :Greeter)
242
+
243
+
244
+
245
+
246
+ ═════════════════ seal point ═════════════════
247
+ First #eval or #run freezes the Service registry
248
+ and snippet table. Further define / preload now
249
+ raise ArgumentError.
250
+
251
+
252
+
253
+
254
+ ──────────────── invocation N ───────────────────
255
+
256
+ 1. allocate fresh mrb_state
257
+
258
+ 2. replay snippets (in insertion order):
259
+ :Adder → defines Adder
260
+ :Greeter → defines Greeter
261
+
262
+ 3. dispatch: eval(source) or run(:Target, *args)
263
+
264
+ 4. return value to host
265
+
266
+ 5. discard mrb_state; reset per-invocation state:
267
+ · Handles invalidated
268
+ · stdout / stderr buffers cleared
269
+ · memory delta zeroed
270
+
271
+ Services + snippets persist; invocation N+1 repeats.
272
+ ```
273
+
209
274
  ```ruby
210
275
  sandbox = Kobako::Sandbox.new
211
276
  sandbox.define(:Data).bind(:Fetch, ->(id) { records[id] })
@@ -240,6 +305,21 @@ The source form trial-compiles each snippet against a fresh `mrb_state` at prelo
240
305
 
241
306
  Snippets replay in insertion order, so later snippets can reference constants defined by earlier ones. The snippet table is sealed by the first invocation alongside Service registration; additional `#preload` calls after the first `#eval` or `#run` raise `ArgumentError`.
242
307
 
308
+ ```
309
+ per-invocation replay (every #eval / #run, snippets in insertion order):
310
+
311
+ fresh mrb_state
312
+
313
+ ├──▶ replay :Adder (defines Adder)
314
+
315
+ ├──▶ replay :Greeter (defines Greeter)
316
+
317
+ └──▶ eval(source) -or- run(:Target, *args, **kwargs)
318
+
319
+
320
+ return value, then mrb_state discarded
321
+ ```
322
+
243
323
  `#run` resolves `target` (Symbol or String, normalized to Symbol) only as a top-level `Object` constant — `::`-segmented names and lowercase forms fail at host pre-flight with `ArgumentError`. A `Kobako::SandboxError` surfaces when the constant is missing or does not respond to `#call`.
244
324
 
245
325
  ### Choosing between source and bytecode
@@ -273,9 +353,9 @@ The ~600 ms cold start dominates the first Sandbox in a process — wasmtime JIT
273
353
 
274
354
  | Allocation | Cost |
275
355
  |---------------------------------------------|----------------------------------------------------------------------------|
276
- | Process RSS after first `Sandbox.new` | ~150-180 MB (one-time engine + module + first instance) |
356
+ | Process RSS after first `Sandbox.new` | ~165-195 MB (one-time engine + module + first instance) |
277
357
  | Per additional Sandbox | ~580 KB (Wasm instance + linear memory + WASI capture pipes) |
278
- | 1 000 isolated tenants in one process | ~750 MB total |
358
+ | 1 000 isolated tenants in one process | ~765 MB total |
279
359
 
280
360
  Use these as upper-bound budgets for capacity planning, not lower bounds — actual RSS shifts ~30% with host process load and macOS allocator state.
281
361
 
data/data/kobako.wasm CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
  authors = ["Aotokitsuruya <contact@aotoki.me>"]
6
6
  license = "Apache-2.0"
@@ -112,16 +112,24 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, MagnusError> {
112
112
  return Err(MagnusError::new(
113
113
  ruby.get_inner(&MODULE_NOT_BUILT_ERROR),
114
114
  format!(
115
- "wasm module not found at {}; run `bundle exec rake wasm:build` to build it",
115
+ "Sandbox runtime not found at {}; run `bundle exec rake wasm:build` to build it",
116
116
  path.display()
117
117
  ),
118
118
  ));
119
119
  }
120
120
 
121
- let bytes =
122
- fs::read(path).map_err(|e| wasm_err(&ruby, format!("read {}: {}", path.display(), e)))?;
121
+ let bytes = fs::read(path).map_err(|e| {
122
+ wasm_err(
123
+ &ruby,
124
+ format!(
125
+ "failed to read Sandbox runtime at {}: {}",
126
+ path.display(),
127
+ e
128
+ ),
129
+ )
130
+ })?;
123
131
  let module = WtModule::new(shared_engine()?, &bytes)
124
- .map_err(|e| wasm_err(&ruby, format!("compile module: {}", e)))?;
132
+ .map_err(|e| wasm_err(&ruby, format!("failed to compile Sandbox runtime: {}", e)))?;
125
133
  cache
126
134
  .lock()
127
135
  .expect("module cache mutex poisoned")
@@ -79,19 +79,20 @@ fn try_handle(
79
79
  req_ptr: i32,
80
80
  req_len: i32,
81
81
  ) -> Result<i64, &'static str> {
82
- let req_bytes = read_caller_memory(caller, req_ptr, req_len)
83
- .ok_or("guest 'memory' export missing or request slice out of bounds")?;
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
+ )?;
84
85
 
85
- // `Kobako::Sandbox` always installs a Server before invoking the
86
- // guest, so reaching this branch indicates a misuse rather than a
87
- // normal control path.
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.
88
89
  let server = caller
89
90
  .data()
90
91
  .server()
91
- .ok_or("no Ruby Server bound Sandbox#run must precede __kobako_dispatch")?;
92
+ .ok_or("RPC dispatched outside an active Sandbox#run internal wiring bug")?;
92
93
 
93
94
  let resp_bytes = invoke_server(server, &req_bytes).map_err(|_| {
94
- "Ruby Server#dispatch raised — contract is to fold faults into Response.err"
95
+ "RPC server raised an exception instead of returning a fault please report this as a kobako bug"
95
96
  })?;
96
97
 
97
98
  write_response(caller, &resp_bytes)
@@ -123,23 +124,23 @@ fn write_response(caller: &mut Caller<'_, HostState>, bytes: &[u8]) -> Result<i6
123
124
  let alloc = match caller.get_export("__kobako_alloc") {
124
125
  Some(Extern::Func(f)) => f
125
126
  .typed::<i32, i32>(&*caller)
126
- .map_err(|_| "guest '__kobako_alloc' export has wrong signature")?,
127
- _ => return Err("guest '__kobako_alloc' export missing"),
127
+ .map_err(|_| "Sandbox runtime's allocation hook has the wrong signature")?,
128
+ _ => return Err("Sandbox runtime is missing the allocation hook"),
128
129
  };
129
- let len_i32 = i32::try_from(bytes.len()).map_err(|_| "response exceeds i32::MAX bytes")?;
130
+ let len_i32 = i32::try_from(bytes.len()).map_err(|_| "RPC response exceeds 2 GiB")?;
130
131
  let ptr = alloc
131
132
  .call(&mut *caller, len_i32)
132
- .map_err(|_| "__kobako_alloc trapped")?;
133
+ .map_err(|_| "Sandbox allocation trapped while preparing the RPC response")?;
133
134
  if ptr == 0 {
134
- return Err("__kobako_alloc returned 0 (out of memory)");
135
+ return Err("Sandbox is out of memory while preparing the RPC response");
135
136
  }
136
137
 
137
138
  let mem = match caller.get_export("memory") {
138
139
  Some(Extern::Memory(m)) => m,
139
- _ => return Err("guest 'memory' export missing"),
140
+ _ => return Err("Sandbox runtime does not export linear memory"),
140
141
  };
141
142
  mem.write(&mut *caller, ptr as usize, bytes)
142
- .map_err(|_| "memory.write rejected response buffer range")?;
143
+ .map_err(|_| "could not write the RPC response into Sandbox memory (range invalid)")?;
143
144
 
144
145
  let ptr_u32 = ptr as u32;
145
146
  let len_u32 = bytes.len() as u32;
@@ -17,7 +17,7 @@
17
17
  //! budget.
18
18
 
19
19
  use std::cell::{Ref, RefCell, RefMut};
20
- use std::time::Instant;
20
+ use std::time::{Duration, Instant};
21
21
 
22
22
  use magnus::{value::Opaque, Value};
23
23
  use wasmtime::{ResourceLimiter, Store as WtStore};
@@ -39,6 +39,8 @@ pub(super) struct HostState {
39
39
  server: Option<Opaque<Value>>,
40
40
  deadline: Option<Instant>,
41
41
  limiter: KobakoLimiter,
42
+ wall_entry: Option<Instant>,
43
+ wall_time: Duration,
42
44
  }
43
45
 
44
46
  impl HostState {
@@ -54,6 +56,8 @@ impl HostState {
54
56
  server: None,
55
57
  deadline: None,
56
58
  limiter: KobakoLimiter::new(memory_limit),
59
+ wall_entry: None,
60
+ wall_time: Duration::ZERO,
57
61
  }
58
62
  }
59
63
 
@@ -156,6 +160,42 @@ impl HostState {
156
160
  pub(super) fn disarm_memory_cap(&mut self) {
157
161
  self.limiter.deactivate();
158
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
+ }
159
199
  }
160
200
 
161
201
  /// Resource limiter that enforces the per-invocation `memory_limit`
@@ -183,6 +223,7 @@ pub(super) struct KobakoLimiter {
183
223
  max_memory: Option<usize>,
184
224
  baseline: usize,
185
225
  cap_active: bool,
226
+ peak: usize,
186
227
  }
187
228
 
188
229
  impl KobakoLimiter {
@@ -191,6 +232,7 @@ impl KobakoLimiter {
191
232
  max_memory,
192
233
  baseline: 0,
193
234
  cap_active: false,
235
+ peak: 0,
194
236
  }
195
237
  }
196
238
 
@@ -200,10 +242,14 @@ impl KobakoLimiter {
200
242
  /// the cap is dormant by default — the module's declared initial
201
243
  /// memory is allocated during `Linker::instantiate` and the
202
244
  /// per-invocation budget excludes anything that existed before
203
- /// arming (docs/behavior.md B-01 Notes, E-20).
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.
204
249
  fn activate(&mut self, baseline: usize) {
205
250
  self.baseline = baseline;
206
251
  self.cap_active = true;
252
+ self.peak = 0;
207
253
  }
208
254
 
209
255
  /// Disarm the cap so post-run host bookkeeping (e.g. fetching the
@@ -213,6 +259,18 @@ impl KobakoLimiter {
213
259
  fn deactivate(&mut self) {
214
260
  self.cap_active = false;
215
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
+ }
216
274
  }
217
275
 
218
276
  impl ResourceLimiter for KobakoLimiter {
@@ -225,12 +283,15 @@ impl ResourceLimiter for KobakoLimiter {
225
283
  if !self.cap_active {
226
284
  return Ok(true);
227
285
  }
286
+ let delta = desired.saturating_sub(self.baseline);
228
287
  if let Some(limit) = self.max_memory {
229
- let delta = desired.saturating_sub(self.baseline);
230
288
  if delta > limit {
231
289
  return Err(wasmtime::Error::new(MemoryLimitTrap { desired, limit }));
232
290
  }
233
291
  }
292
+ if delta > self.peak {
293
+ self.peak = delta;
294
+ }
234
295
  Ok(true)
235
296
  }
236
297
 
@@ -270,7 +331,8 @@ impl std::fmt::Display for MemoryLimitTrap {
270
331
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271
332
  write!(
272
333
  f,
273
- "guest memory.grow would exceed memory_limit: desired={} bytes, limit={} bytes",
334
+ "linear memory growth exceeded memory_limit: \
335
+ desired={} bytes, limit={} bytes",
274
336
  self.desired, self.limit
275
337
  )
276
338
  }
@@ -286,7 +348,7 @@ pub(crate) struct TimeoutTrap;
286
348
 
287
349
  impl std::fmt::Display for TimeoutTrap {
288
350
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289
- write!(f, "guest exceeded the configured wall-clock timeout")
351
+ write!(f, "wall-clock deadline exceeded")
290
352
  }
291
353
  }
292
354
 
@@ -420,4 +482,48 @@ mod tests {
420
482
  limiter.activate(0);
421
483
  assert_growing(&mut limiter, 100 << 20);
422
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
+ }
423
529
  }