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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -0
- data/CHANGELOG.md +29 -0
- data/Cargo.lock +1 -1
- data/README.md +85 -6
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +1 -1
- data/ext/kobako/src/lib.rs +4 -2
- data/ext/kobako/src/{wasm → runtime}/cache.rs +22 -18
- data/ext/kobako/src/runtime/capture.rs +91 -0
- data/ext/kobako/src/runtime/config.rs +26 -0
- data/ext/kobako/src/runtime/dispatch.rs +211 -0
- data/ext/kobako/src/runtime/exports.rs +51 -0
- data/ext/kobako/src/runtime/guest_mem.rs +228 -0
- data/ext/kobako/src/{wasm/host_state.rs → runtime/invocation.rs} +195 -81
- data/ext/kobako/src/runtime/trap.rs +134 -0
- data/ext/kobako/src/runtime.rs +782 -0
- data/ext/kobako/src/snapshot.rs +110 -0
- data/lib/kobako/capture.rb +11 -16
- data/lib/kobako/catalog/handles.rb +107 -0
- data/lib/kobako/catalog/namespaces.rb +99 -0
- data/lib/kobako/{snippet/table.rb → catalog/snippets.rb} +37 -62
- data/lib/kobako/catalog.rb +18 -0
- data/lib/kobako/codec/decoder.rb +13 -7
- data/lib/kobako/codec/factory.rb +21 -18
- data/lib/kobako/codec/utils.rb +118 -29
- data/lib/kobako/codec.rb +6 -3
- data/lib/kobako/errors.rb +45 -28
- data/lib/kobako/fault.rb +40 -0
- data/lib/kobako/handle.rb +60 -0
- data/lib/kobako/namespace.rb +67 -0
- data/lib/kobako/outcome.rb +55 -29
- data/lib/kobako/runtime.rb +30 -0
- data/lib/kobako/sandbox.rb +131 -67
- data/lib/kobako/sandbox_options.rb +6 -9
- data/lib/kobako/snapshot.rb +40 -0
- data/lib/kobako/snippet/binary.rb +6 -7
- data/lib/kobako/snippet/source.rb +8 -8
- data/lib/kobako/snippet.rb +7 -9
- data/lib/kobako/transport/dispatcher.rb +195 -0
- data/lib/kobako/transport/error.rb +24 -0
- data/lib/kobako/transport/request.rb +78 -0
- data/lib/kobako/transport/response.rb +69 -0
- data/lib/kobako/transport/run.rb +141 -0
- data/lib/kobako/transport/yield.rb +91 -0
- data/lib/kobako/transport/yielder.rb +89 -0
- data/lib/kobako/transport.rb +24 -0
- data/lib/kobako/usage.rb +41 -0
- data/lib/kobako/version.rb +1 -1
- data/lib/kobako.rb +4 -3
- data/release-please-config.json +24 -0
- data/sig/kobako/capture.rbs +0 -2
- data/sig/kobako/{rpc/handle_table.rbs → catalog/handles.rbs} +3 -9
- data/sig/kobako/catalog/namespaces.rbs +17 -0
- data/sig/kobako/{snippet/table.rbs → catalog/snippets.rbs} +2 -11
- data/sig/kobako/{rpc.rbs → catalog.rbs} +1 -1
- data/sig/kobako/codec/decoder.rbs +2 -1
- data/sig/kobako/codec/factory.rbs +3 -3
- data/sig/kobako/codec/utils.rbs +11 -1
- data/sig/kobako/errors.rbs +7 -7
- data/sig/kobako/fault.rbs +19 -0
- data/sig/kobako/handle.rbs +18 -0
- data/sig/kobako/namespace.rbs +19 -0
- data/sig/kobako/outcome.rbs +2 -2
- data/sig/kobako/runtime.rbs +23 -0
- data/sig/kobako/sandbox.rbs +10 -7
- data/sig/kobako/snapshot.rbs +15 -0
- data/sig/kobako/transport/dispatcher.rbs +34 -0
- data/sig/kobako/transport/error.rbs +6 -0
- data/sig/kobako/transport/request.rbs +32 -0
- data/sig/kobako/transport/response.rbs +30 -0
- data/sig/kobako/transport/run.rbs +27 -0
- data/sig/kobako/transport/yield.rbs +34 -0
- data/sig/kobako/transport/yielder.rbs +21 -0
- data/sig/kobako/transport.rbs +4 -0
- data/sig/kobako/usage.rbs +11 -0
- metadata +52 -30
- data/ext/kobako/src/wasm/dispatch.rs +0 -161
- data/ext/kobako/src/wasm/instance.rs +0 -771
- data/ext/kobako/src/wasm.rs +0 -125
- data/lib/kobako/invocation.rb +0 -112
- data/lib/kobako/rpc/dispatcher.rb +0 -169
- data/lib/kobako/rpc/envelope.rb +0 -118
- data/lib/kobako/rpc/fault.rb +0 -41
- data/lib/kobako/rpc/handle.rb +0 -39
- data/lib/kobako/rpc/handle_table.rb +0 -107
- data/lib/kobako/rpc/namespace.rb +0 -74
- data/lib/kobako/rpc/server.rb +0 -158
- data/lib/kobako/rpc.rb +0 -11
- data/lib/kobako/wasm.rb +0 -25
- data/sig/kobako/invocation.rbs +0 -23
- data/sig/kobako/rpc/dispatcher.rbs +0 -33
- data/sig/kobako/rpc/envelope.rbs +0 -51
- data/sig/kobako/rpc/fault.rbs +0 -20
- data/sig/kobako/rpc/handle.rbs +0 -19
- data/sig/kobako/rpc/namespace.rbs +0 -24
- data/sig/kobako/rpc/server.rbs +0 -37
- data/sig/kobako/wasm.rbs +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd04d7b8efaf9ccb41ff873a66b0855839222c5bc034d80a854b9fbc25602570
|
|
4
|
+
data.tar.gz: 718665fbcfb89faafc86f018a247e37ea304c3c8559e4febc240ad95bdc15654
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9cd6493f200abbb9a9014e9222633fec67e8813a854b93e71a29d94a3738ff64dfe33f5b136efe472d87831fb78062a09ac481d1da6970782e965a7a7aeafc8
|
|
7
|
+
data.tar.gz: 7f11f9fb3e48efc808eebd0598bc19569c6c1fd90322cee2f65f9030d8ca39a3da6cbe9fa68edb33480e926aabcb3766c2dc8c37995945ceaebd7b9952d9ec0f
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{".":"0.5.0"}
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.5.0](https://github.com/elct9620/kobako/compare/v0.4.0...v0.5.0) (2026-05-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **abi:** add `__kobako_yield_to_block` skeleton + host re-entry channel ([555eb4b](https://github.com/elct9620/kobako/commit/555eb4bf578c3c4397ba2c0d105c0d3ca687e23c))
|
|
9
|
+
* **abi:** classify RBreak via ci_break_index for B-25 / E-21 ([32668a0](https://github.com/elct9620/kobako/commit/32668a033e2f959700acadadfbc41388ed72a2dd))
|
|
10
|
+
* **abi:** wire `__kobako_yield_to_block` to real `mrb_yield_argv` ([35aeac8](https://github.com/elct9620/kobako/commit/35aeac8700254d1500f5be837a72c56984a7ebfa))
|
|
11
|
+
* **bench:** add noise-aware release gate, report mean alongside median ([0cfaebc](https://github.com/elct9620/kobako/commit/0cfaebc2afadfae81e3d00441273da70e396d7a5))
|
|
12
|
+
* **bench:** add yield round-trip suite as gated benchmark [#6](https://github.com/elct9620/kobako/issues/6) ([315f923](https://github.com/elct9620/kobako/commit/315f923caa89bcd8752a611525da68ae53ae092f))
|
|
13
|
+
* **catalog:** introduce empty Kobako::Catalog namespace ([8af8c54](https://github.com/elct9620/kobako/commit/8af8c54c72e5e5193555bcc2e86072d4a4d8176d))
|
|
14
|
+
* **ext:** enforce the 16 MiB single-dispatch payload cap on host boundaries ([c80e281](https://github.com/elct9620/kobako/commit/c80e281e0810640c60d93174beddd49a31c34182))
|
|
15
|
+
* **guest:** capture guest blocks via `n*&` argspec + LIFO BLOCK_STACK ([aa55556](https://github.com/elct9620/kobako/commit/aa55556aab23c159078d0ba0ea47ed878b26e89d))
|
|
16
|
+
* **rpc:** build block proxy for guest-supplied yield blocks ([b6d6cf7](https://github.com/elct9620/kobako/commit/b6d6cf7f5ca857f55aafea62631b243f688c61a6))
|
|
17
|
+
* **rpc:** catch/throw + frame invalidator close B-25 / B-28 / E-23 ([3b21f25](https://github.com/elct9620/kobako/commit/3b21f252fafdd2070f3953460509e24a0e643d88))
|
|
18
|
+
* **transport:** introduce empty Kobako::Transport namespace ([85cda26](https://github.com/elct9620/kobako/commit/85cda268000490f521424339bec1664d0b33478b))
|
|
19
|
+
* **wire:** add `block_given` field to Request envelope ([30e004f](https://github.com/elct9620/kobako/commit/30e004fa8f00739e68883889c5225c98cf9521fe))
|
|
20
|
+
* **wire:** add YieldResponse envelope codec on both sides ([4592567](https://github.com/elct9620/kobako/commit/459256784af616d70738ffd0f56c3b15244b3e7c))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* **bench:** restore renamed class references so rake bench runs ([76140cc](https://github.com/elct9620/kobako/commit/76140cc99922973fc305aab6ba727a832ddbe7ba))
|
|
26
|
+
* **ext:** GC-root the dispatch Proc via a pinning mark on Kobako::Runtime ([f31bd07](https://github.com/elct9620/kobako/commit/f31bd071201b5fed7376bd13b876f103d6c6a5d6))
|
|
27
|
+
* **ext:** raise SandboxError, not TrapError, when #run envelope alloc fails ([a1981fe](https://github.com/elct9620/kobako/commit/a1981fea7438090a76758147e7e84543e9d96968))
|
|
28
|
+
* **transport:** fill E-xx placeholder and drop BLOCK_RESEARCH citations ([816ff80](https://github.com/elct9620/kobako/commit/816ff804535196036bec01fcd980e25036211b80))
|
|
29
|
+
* **wasm:** reject unrepresentable guest return values instead of stringifying ([c3fd069](https://github.com/elct9620/kobako/commit/c3fd0698cb168b55502fb86065406caf9a7744e1))
|
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.
|
|
@@ -177,13 +191,12 @@ end
|
|
|
177
191
|
|----------------------------------------|--------------------|------------------------------------------------------------------------------------------|
|
|
178
192
|
| `Kobako::TimeoutError` | `TrapError` | Per-invocation `timeout` exhausted |
|
|
179
193
|
| `Kobako::MemoryLimitError` | `TrapError` | Per-invocation `memory_limit` exhausted |
|
|
180
|
-
| `Kobako::ServiceError::Disconnected` | `ServiceError` | RPC target Handle has been invalidated |
|
|
181
194
|
| `Kobako::HandleTableExhausted` | `SandboxError` | Per-invocation Handle counter reached its 2³¹ − 1 cap |
|
|
182
195
|
| `Kobako::BytecodeError` | `SandboxError` | `#preload(binary:)` payload failed RITE structural validation at first invocation replay |
|
|
183
196
|
|
|
184
197
|
## Capability Handles
|
|
185
198
|
|
|
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::
|
|
199
|
+
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
200
|
|
|
188
201
|
```ruby
|
|
189
202
|
class Greeter
|
|
@@ -194,18 +207,69 @@ end
|
|
|
194
207
|
sandbox.define(:Factory).bind(:Make, ->(name) { Greeter.new(name) })
|
|
195
208
|
|
|
196
209
|
sandbox.eval(<<~RUBY)
|
|
197
|
-
g = Factory::Make.call("Bob") # g is a Kobako::
|
|
210
|
+
g = Factory::Make.call("Bob") # g is a Kobako::Handle proxy
|
|
198
211
|
g.greet # second RPC, routed to the Greeter
|
|
199
212
|
RUBY
|
|
200
213
|
# => "hi, Bob"
|
|
201
214
|
```
|
|
202
215
|
|
|
216
|
+
`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.
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
require "stringio"
|
|
220
|
+
|
|
221
|
+
sandbox = Kobako::Sandbox.new
|
|
222
|
+
sandbox.preload(code: "Echo = ->(body) { body.read.upcase }", name: :Echo)
|
|
223
|
+
|
|
224
|
+
sandbox.run(:Echo, StringIO.new("hello world"))
|
|
225
|
+
# => "HELLO WORLD"
|
|
226
|
+
```
|
|
227
|
+
|
|
203
228
|
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
229
|
|
|
205
230
|
## Setup-once, run-many
|
|
206
231
|
|
|
207
232
|
A single Sandbox can serve many invocations. Service bindings and preloaded snippets persist; capability state (Handles, stdout, stderr) resets between invocations.
|
|
208
233
|
|
|
234
|
+
```
|
|
235
|
+
───────────── setup phase (mutable) ─────────────
|
|
236
|
+
|
|
237
|
+
sandbox = Kobako::Sandbox.new
|
|
238
|
+
sandbox.define(:KV).bind(:Lookup, ...)
|
|
239
|
+
sandbox.preload(code: ..., name: :Adder)
|
|
240
|
+
sandbox.preload(code: ..., name: :Greeter)
|
|
241
|
+
|
|
242
|
+
│
|
|
243
|
+
▼
|
|
244
|
+
|
|
245
|
+
═════════════════ seal point ═════════════════
|
|
246
|
+
First #eval or #run freezes the Service registry
|
|
247
|
+
and snippet table. Further define / preload now
|
|
248
|
+
raise ArgumentError.
|
|
249
|
+
|
|
250
|
+
│
|
|
251
|
+
▼
|
|
252
|
+
|
|
253
|
+
──────────────── invocation N ───────────────────
|
|
254
|
+
|
|
255
|
+
1. allocate fresh mrb_state
|
|
256
|
+
|
|
257
|
+
2. replay snippets (in insertion order):
|
|
258
|
+
:Adder → defines Adder
|
|
259
|
+
:Greeter → defines Greeter
|
|
260
|
+
|
|
261
|
+
3. dispatch: eval(source) or run(:Target, *args)
|
|
262
|
+
|
|
263
|
+
4. return value to host
|
|
264
|
+
|
|
265
|
+
5. discard mrb_state; reset per-invocation state:
|
|
266
|
+
· Handles invalidated
|
|
267
|
+
· stdout / stderr buffers cleared
|
|
268
|
+
· memory delta zeroed
|
|
269
|
+
|
|
270
|
+
Services + snippets persist; invocation N+1 repeats.
|
|
271
|
+
```
|
|
272
|
+
|
|
209
273
|
```ruby
|
|
210
274
|
sandbox = Kobako::Sandbox.new
|
|
211
275
|
sandbox.define(:Data).bind(:Fetch, ->(id) { records[id] })
|
|
@@ -240,6 +304,21 @@ The source form trial-compiles each snippet against a fresh `mrb_state` at prelo
|
|
|
240
304
|
|
|
241
305
|
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
306
|
|
|
307
|
+
```
|
|
308
|
+
per-invocation replay (every #eval / #run, snippets in insertion order):
|
|
309
|
+
|
|
310
|
+
fresh mrb_state
|
|
311
|
+
│
|
|
312
|
+
├──▶ replay :Adder (defines Adder)
|
|
313
|
+
│
|
|
314
|
+
├──▶ replay :Greeter (defines Greeter)
|
|
315
|
+
│
|
|
316
|
+
└──▶ eval(source) -or- run(:Target, *args, **kwargs)
|
|
317
|
+
│
|
|
318
|
+
▼
|
|
319
|
+
return value, then mrb_state discarded
|
|
320
|
+
```
|
|
321
|
+
|
|
243
322
|
`#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
323
|
|
|
245
324
|
### Choosing between source and bytecode
|
|
@@ -273,9 +352,9 @@ The ~600 ms cold start dominates the first Sandbox in a process — wasmtime JIT
|
|
|
273
352
|
|
|
274
353
|
| Allocation | Cost |
|
|
275
354
|
|---------------------------------------------|----------------------------------------------------------------------------|
|
|
276
|
-
| Process RSS after first `Sandbox.new` | ~
|
|
355
|
+
| Process RSS after first `Sandbox.new` | ~165-195 MB (one-time engine + module + first instance) |
|
|
277
356
|
| Per additional Sandbox | ~580 KB (Wasm instance + linear memory + WASI capture pipes) |
|
|
278
|
-
| 1 000 isolated tenants in one process | ~
|
|
357
|
+
| 1 000 isolated tenants in one process | ~765 MB total |
|
|
279
358
|
|
|
280
359
|
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
360
|
|
data/data/kobako.wasm
CHANGED
|
Binary file
|
data/ext/kobako/Cargo.toml
CHANGED
data/ext/kobako/src/lib.rs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
use magnus::{Error, Ruby};
|
|
2
2
|
|
|
3
|
-
mod
|
|
3
|
+
mod runtime;
|
|
4
|
+
mod snapshot;
|
|
4
5
|
|
|
5
6
|
#[magnus::init]
|
|
6
7
|
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
7
8
|
let module = ruby.define_module("Kobako")?;
|
|
8
|
-
|
|
9
|
+
runtime::init(ruby, module)?;
|
|
10
|
+
snapshot::init(ruby, module)?;
|
|
9
11
|
Ok(())
|
|
10
12
|
}
|
|
@@ -1,22 +1,18 @@
|
|
|
1
|
-
//! Process-wide caches for the wasmtime
|
|
2
|
-
//!
|
|
1
|
+
//! Process-wide caches for the wasmtime `Engine` and compiled
|
|
2
|
+
//! `Module`.
|
|
3
3
|
//!
|
|
4
4
|
//! SPEC.md "Code Organization" pins `ext/` as private and forbids
|
|
5
5
|
//! exposing wasm engine types to the Host App or downstream gems. To
|
|
6
6
|
//! amortise Engine creation and Module JIT compilation across multiple
|
|
7
7
|
//! `Kobako::Sandbox` constructions, the ext keeps a process-scope
|
|
8
8
|
//! shared Engine and a per-path Module cache. Both are transparent to
|
|
9
|
-
//! Ruby callers, who construct
|
|
10
|
-
//! `Kobako::
|
|
11
|
-
//! Module.
|
|
9
|
+
//! Ruby callers, who construct a `Runtime` via
|
|
10
|
+
//! `Kobako::Runtime.from_path(...)` and never see Engine or Module.
|
|
12
11
|
//!
|
|
13
12
|
//! Concurrency: under Ruby's GVL only one thread can execute Rust code
|
|
14
13
|
//! at a time, so the Mutex is held briefly during HashMap insert/lookup
|
|
15
14
|
//! and serves to satisfy `Sync` bounds rather than to arbitrate real
|
|
16
15
|
//! contention.
|
|
17
|
-
//!
|
|
18
|
-
//! [`Engine`]: wasmtime::Engine
|
|
19
|
-
//! [`Module`]: wasmtime::Module
|
|
20
16
|
|
|
21
17
|
use std::collections::HashMap;
|
|
22
18
|
use std::fs;
|
|
@@ -28,7 +24,7 @@ use std::time::Duration;
|
|
|
28
24
|
use magnus::{Error as MagnusError, Ruby};
|
|
29
25
|
use wasmtime::{Config as WtConfig, Engine as WtEngine, Module as WtModule};
|
|
30
26
|
|
|
31
|
-
use super::{
|
|
27
|
+
use super::{setup_err, MODULE_NOT_BUILT_ERROR};
|
|
32
28
|
|
|
33
29
|
static SHARED_ENGINE: OnceLock<WtEngine> = OnceLock::new();
|
|
34
30
|
static MODULE_CACHE: OnceLock<Mutex<HashMap<PathBuf, WtModule>>> = OnceLock::new();
|
|
@@ -55,7 +51,7 @@ const EPOCH_TICK: Duration = Duration::from_millis(10);
|
|
|
55
51
|
/// Also enables `epoch_interruption(true)` so every Store can install an
|
|
56
52
|
/// `epoch_deadline_callback` for the per-run wall-clock cap
|
|
57
53
|
/// (docs/behavior.md B-01, E-19). The first call spawns the process-singleton ticker
|
|
58
|
-
/// thread that drives `engine.increment_epoch()` at
|
|
54
|
+
/// thread that drives `engine.increment_epoch()` at `EPOCH_TICK`
|
|
59
55
|
/// cadence; subsequent calls reuse the same engine and ticker.
|
|
60
56
|
pub(crate) fn shared_engine() -> Result<&'static WtEngine, MagnusError> {
|
|
61
57
|
if let Some(engine) = SHARED_ENGINE.get() {
|
|
@@ -66,7 +62,7 @@ pub(crate) fn shared_engine() -> Result<&'static WtEngine, MagnusError> {
|
|
|
66
62
|
config.epoch_interruption(true);
|
|
67
63
|
let engine = WtEngine::new(&config).map_err(|e| {
|
|
68
64
|
let ruby = Ruby::get().expect("Ruby thread");
|
|
69
|
-
|
|
65
|
+
setup_err(&ruby, format!("engine init: {}", e))
|
|
70
66
|
})?;
|
|
71
67
|
let engine = SHARED_ENGINE.get_or_init(|| engine);
|
|
72
68
|
spawn_epoch_ticker(engine.clone());
|
|
@@ -75,8 +71,8 @@ pub(crate) fn shared_engine() -> Result<&'static WtEngine, MagnusError> {
|
|
|
75
71
|
|
|
76
72
|
/// Spawn the process-singleton epoch ticker. The thread holds a clone of
|
|
77
73
|
/// the shared Engine (`wasmtime::Engine` is reference-counted internally)
|
|
78
|
-
/// and ticks the epoch counter at
|
|
79
|
-
/// across reentrant calls to
|
|
74
|
+
/// and ticks the epoch counter at `EPOCH_TICK` cadence. Idempotent
|
|
75
|
+
/// across reentrant calls to `shared_engine` because `OnceLock`
|
|
80
76
|
/// gates the spawn.
|
|
81
77
|
fn spawn_epoch_ticker(engine: WtEngine) {
|
|
82
78
|
static TICKER_SPAWNED: OnceLock<()> = OnceLock::new();
|
|
@@ -92,7 +88,7 @@ fn spawn_epoch_ticker(engine: WtEngine) {
|
|
|
92
88
|
}
|
|
93
89
|
|
|
94
90
|
/// Look up `path` in the per-path Module cache, compiling and inserting
|
|
95
|
-
/// the artifact on a miss. Raises `Kobako::
|
|
91
|
+
/// the artifact on a miss. Raises `Kobako::ModuleNotBuiltError`
|
|
96
92
|
/// when the file is missing — the headline error for the common
|
|
97
93
|
/// pre-build state on a fresh clone before `rake compile`.
|
|
98
94
|
pub(crate) fn cached_module(path: &Path) -> Result<WtModule, MagnusError> {
|
|
@@ -112,16 +108,24 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, MagnusError> {
|
|
|
112
108
|
return Err(MagnusError::new(
|
|
113
109
|
ruby.get_inner(&MODULE_NOT_BUILT_ERROR),
|
|
114
110
|
format!(
|
|
115
|
-
"
|
|
111
|
+
"Sandbox runtime not found at {}; run `bundle exec rake wasm:build` to build it",
|
|
116
112
|
path.display()
|
|
117
113
|
),
|
|
118
114
|
));
|
|
119
115
|
}
|
|
120
116
|
|
|
121
|
-
let bytes =
|
|
122
|
-
|
|
117
|
+
let bytes = fs::read(path).map_err(|e| {
|
|
118
|
+
setup_err(
|
|
119
|
+
&ruby,
|
|
120
|
+
format!(
|
|
121
|
+
"failed to read Sandbox runtime at {}: {}",
|
|
122
|
+
path.display(),
|
|
123
|
+
e
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
})?;
|
|
123
127
|
let module = WtModule::new(shared_engine()?, &bytes)
|
|
124
|
-
.map_err(|e|
|
|
128
|
+
.map_err(|e| setup_err(&ruby, format!("failed to compile Sandbox runtime: {}", e)))?;
|
|
125
129
|
cache
|
|
126
130
|
.lock()
|
|
127
131
|
.expect("module cache mutex poisoned")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
//! Per-channel stdout / stderr capture sizing and clipping.
|
|
2
|
+
//!
|
|
3
|
+
//! Two pure helpers shared by the run path (docs/behavior.md B-04): one
|
|
4
|
+
//! sizes the per-run `MemoryOutputPipe`, the other clips a captured
|
|
5
|
+
//! snapshot back to the configured cap and reports whether the cap was
|
|
6
|
+
//! exceeded. Kept channel-agnostic (a function of `cap`, not of which
|
|
7
|
+
//! channel) so a regression that only breaks one channel cannot sneak
|
|
8
|
+
//! through the test that pins them.
|
|
9
|
+
|
|
10
|
+
/// Translate a per-channel byte cap into the MemoryOutputPipe capacity:
|
|
11
|
+
/// `cap + 1` (saturated against `usize::MAX`) when a cap is set so the
|
|
12
|
+
/// "wrote exactly cap" and "exceeded cap" cases stay distinguishable;
|
|
13
|
+
/// `usize::MAX` when the channel is uncapped.
|
|
14
|
+
pub(super) fn pipe_capacity(cap: Option<usize>) -> usize {
|
|
15
|
+
match cap {
|
|
16
|
+
Some(c) => c.saturating_add(1),
|
|
17
|
+
None => usize::MAX,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Pure slicing core shared by the snapshot readback: given the unclipped
|
|
22
|
+
/// pipe snapshot and the configured cap, return the bytes Ruby should
|
|
23
|
+
/// observe (clipped to `cap`) plus the truncation flag. `truncated` is
|
|
24
|
+
/// `true` only when the snapshot strictly exceeded the cap — this is the
|
|
25
|
+
/// "wrote `cap + 1` bytes into a `cap + 1`-sized pipe" case; "wrote
|
|
26
|
+
/// exactly `cap` bytes" stays `false`.
|
|
27
|
+
pub(super) fn clip_capture(raw: &[u8], cap: Option<usize>) -> (&[u8], bool) {
|
|
28
|
+
match cap {
|
|
29
|
+
Some(c) if raw.len() > c => (&raw[..c], true),
|
|
30
|
+
_ => (raw, false),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[cfg(test)]
|
|
35
|
+
mod tests {
|
|
36
|
+
use super::{clip_capture, pipe_capacity};
|
|
37
|
+
|
|
38
|
+
#[test]
|
|
39
|
+
fn pipe_capacity_adds_one_when_cap_is_set() {
|
|
40
|
+
assert_eq!(pipe_capacity(Some(5)), 6);
|
|
41
|
+
assert_eq!(pipe_capacity(Some(0)), 1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[test]
|
|
45
|
+
fn pipe_capacity_falls_back_to_usize_max_when_uncapped() {
|
|
46
|
+
assert_eq!(pipe_capacity(None), usize::MAX);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[test]
|
|
50
|
+
fn pipe_capacity_saturates_at_usize_max() {
|
|
51
|
+
assert_eq!(pipe_capacity(Some(usize::MAX)), usize::MAX);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#[test]
|
|
55
|
+
fn clip_capture_returns_full_bytes_when_under_cap() {
|
|
56
|
+
let (bytes, truncated) = clip_capture(b"abc", Some(5));
|
|
57
|
+
assert_eq!(bytes, b"abc");
|
|
58
|
+
assert!(!truncated);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#[test]
|
|
62
|
+
fn clip_capture_does_not_flag_truncation_at_exactly_cap_bytes() {
|
|
63
|
+
let (bytes, truncated) = clip_capture(b"abcde", Some(5));
|
|
64
|
+
assert_eq!(bytes, b"abcde");
|
|
65
|
+
assert!(!truncated);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[test]
|
|
69
|
+
fn clip_capture_clips_to_cap_and_flags_truncation_on_overflow() {
|
|
70
|
+
// The pipe is sized `cap + 1`, so the snapshot can be at most
|
|
71
|
+
// 6 bytes when `cap == 5`; that surface is what triggers the
|
|
72
|
+
// truncation flag.
|
|
73
|
+
let (bytes, truncated) = clip_capture(b"abcdef", Some(5));
|
|
74
|
+
assert_eq!(bytes, b"abcde");
|
|
75
|
+
assert!(truncated);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#[test]
|
|
79
|
+
fn clip_capture_treats_none_as_uncapped() {
|
|
80
|
+
let (bytes, truncated) = clip_capture(b"abcdef", None);
|
|
81
|
+
assert_eq!(bytes, b"abcdef");
|
|
82
|
+
assert!(!truncated);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#[test]
|
|
86
|
+
fn clip_capture_handles_empty_input() {
|
|
87
|
+
let (bytes, truncated) = clip_capture(b"", Some(5));
|
|
88
|
+
assert_eq!(bytes, b"");
|
|
89
|
+
assert!(!truncated);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//! Per-`Runtime` execution configuration.
|
|
2
|
+
//!
|
|
3
|
+
//! The wall-clock and per-channel capture caps a `Kobako::Sandbox`
|
|
4
|
+
//! forwards into `Runtime::from_path`. A plain value carrier owned by the
|
|
5
|
+
//! `Runtime` — distinct from the process-wide engine/module `super::cache`
|
|
6
|
+
//! (which is shared across every Sandbox) and from the per-invocation
|
|
7
|
+
//! `super::invocation::Invocation` (which the wasm engine mutates from
|
|
8
|
+
//! inside a run). These caps are read only by `Runtime` methods between
|
|
9
|
+
//! runs, so they live here.
|
|
10
|
+
|
|
11
|
+
use std::time::Duration;
|
|
12
|
+
|
|
13
|
+
/// Wall-clock and output caps for one `Runtime`. `None` on any field
|
|
14
|
+
/// disables that cap.
|
|
15
|
+
pub(crate) struct Config {
|
|
16
|
+
/// Wall-clock cap for one guest `#eval` / `#run` (docs/behavior.md
|
|
17
|
+
/// B-01, E-19). Stamped into a per-run `Instant` deadline by
|
|
18
|
+
/// `Runtime::prime_caps`.
|
|
19
|
+
pub(crate) timeout: Option<Duration>,
|
|
20
|
+
/// Byte cap for guest stdout capture (docs/behavior.md B-01 / B-04).
|
|
21
|
+
/// Sizes the per-run `MemoryOutputPipe` and computes the truncation
|
|
22
|
+
/// flag in `Runtime::build_snapshot`.
|
|
23
|
+
pub(crate) stdout_limit_bytes: Option<usize>,
|
|
24
|
+
/// Byte cap for guest stderr capture. Mirror of `stdout_limit_bytes`.
|
|
25
|
+
pub(crate) stderr_limit_bytes: Option<usize>,
|
|
26
|
+
}
|