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.
- checksums.yaml +4 -4
- data/Cargo.lock +1 -1
- data/README.md +85 -5
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +1 -1
- data/ext/kobako/src/wasm/cache.rs +12 -4
- data/ext/kobako/src/wasm/dispatch.rs +15 -14
- data/ext/kobako/src/wasm/host_state.rs +111 -5
- data/ext/kobako/src/wasm/instance.rs +135 -33
- data/ext/kobako/src/wasm.rs +1 -0
- data/lib/kobako/codec/decoder.rb +0 -2
- data/lib/kobako/codec/factory.rb +13 -10
- data/lib/kobako/codec/utils.rb +105 -13
- data/lib/kobako/handle.rb +62 -0
- data/lib/kobako/handle_table.rb +119 -0
- data/lib/kobako/invocation.rb +56 -25
- data/lib/kobako/outcome.rb +42 -12
- data/lib/kobako/rpc/dispatcher.rb +22 -20
- data/lib/kobako/rpc/envelope.rb +7 -7
- data/lib/kobako/rpc/fault.rb +1 -1
- data/lib/kobako/rpc/server.rb +12 -24
- data/lib/kobako/rpc/wire_error.rb +23 -0
- data/lib/kobako/sandbox.rb +77 -24
- 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/handle.rbs +19 -0
- data/sig/kobako/handle_table.rbs +23 -0
- data/sig/kobako/invocation.rbs +3 -1
- 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 +7 -1
- data/sig/kobako/usage.rbs +11 -0
- data/sig/kobako/wasm.rbs +2 -0
- metadata +9 -5
- data/lib/kobako/rpc/handle.rb +0 -39
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb91cd11e954d6388b7d6c19be8b9fc77548fa1ea9d57b75f1afc7c0d450a36b
|
|
4
|
+
data.tar.gz: f84463e4b30e2ae5cb1e7d09a7c55345a419afd442613a1eb6b080682263587f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d622978cf22e2b30dbf8674275bbaaf39d0de68962709b40b67194d0521ca3d1e991e9a4e59853e634c111fc852d4719864c2990f2baaf35e1395efa3f67b63a
|
|
7
|
+
data.tar.gz: 3f828b5374841d0bcb8136a7c1aa078668c05c3673c794c50f86de9b1aee0bd915d090e022061b3e9636abb2dfb85a0b3bf1a7bbacff968635d9ed01c5f21edd
|
data/Cargo.lock
CHANGED
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::
|
|
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::
|
|
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::
|
|
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` | ~
|
|
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 | ~
|
|
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
|
data/ext/kobako/Cargo.toml
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
86
|
-
//
|
|
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("
|
|
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
|
-
"
|
|
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(|_| "
|
|
127
|
-
_ => return Err("
|
|
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
|
|
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(|_| "
|
|
133
|
+
.map_err(|_| "Sandbox allocation trapped while preparing the RPC response")?;
|
|
133
134
|
if ptr == 0 {
|
|
134
|
-
return Err("
|
|
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("
|
|
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(|_| "
|
|
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
|
-
"
|
|
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, "
|
|
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
|
}
|