kobako 0.9.2 → 0.11.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 -1
- data/CHANGELOG.md +32 -0
- data/Cargo.lock +3 -1
- data/README.md +47 -19
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +12 -2
- data/ext/kobako/src/runtime/ambient.rs +1 -1
- data/ext/kobako/src/runtime/cache.rs +170 -6
- data/ext/kobako/src/runtime/capture.rs +1 -1
- data/ext/kobako/src/runtime/config.rs +3 -4
- data/ext/kobako/src/runtime/dispatch.rs +8 -8
- data/ext/kobako/src/runtime/exports.rs +32 -21
- data/ext/kobako/src/runtime/instance_pre.rs +97 -0
- data/ext/kobako/src/runtime/invocation.rs +36 -93
- data/ext/kobako/src/runtime/trap.rs +5 -5
- data/ext/kobako/src/runtime.rs +389 -403
- data/ext/kobako/src/snapshot.rs +2 -2
- data/lib/kobako/capture.rb +5 -7
- data/lib/kobako/catalog/handles.rb +28 -39
- data/lib/kobako/catalog/namespaces.rb +31 -20
- data/lib/kobako/catalog/snippets.rb +18 -16
- data/lib/kobako/codec/decoder.rb +5 -1
- data/lib/kobako/codec/utils.rb +6 -9
- data/lib/kobako/errors.rb +40 -36
- data/lib/kobako/handle.rb +2 -3
- data/lib/kobako/namespace.rb +17 -6
- data/lib/kobako/outcome.rb +12 -14
- data/lib/kobako/pool.rb +176 -0
- data/lib/kobako/sandbox.rb +68 -88
- data/lib/kobako/sandbox_options.rb +5 -9
- data/lib/kobako/snapshot.rb +2 -4
- data/lib/kobako/snippet/binary.rb +1 -3
- data/lib/kobako/snippet/source.rb +1 -2
- data/lib/kobako/snippet.rb +1 -2
- data/lib/kobako/transport/dispatcher.rb +39 -38
- data/lib/kobako/transport/request.rb +1 -1
- data/lib/kobako/transport/run.rb +23 -28
- data/lib/kobako/transport/yielder.rb +11 -17
- data/lib/kobako/transport.rb +2 -3
- data/lib/kobako/usage.rb +10 -13
- data/lib/kobako/version.rb +1 -1
- data/lib/kobako.rb +1 -0
- data/release-please-config.json +16 -1
- data/sig/kobako/catalog/handles.rbs +0 -2
- data/sig/kobako/errors.rbs +3 -0
- data/sig/kobako/namespace.rbs +2 -0
- data/sig/kobako/pool.rbs +44 -0
- data/sig/kobako/sandbox.rbs +2 -2
- data/sig/kobako/transport/dispatcher.rbs +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc8e33cc57bfd43cf4d4e06def6536c8c0d45fd4ccd5bbf04b13a4187db67f0a
|
|
4
|
+
data.tar.gz: 8656976c144bb23226686b2c8f60830d3fd635f637c52caaa824bd3e168e07f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 35258fb35a0accee9beea36f01c4fb6cb2c658a2aefcb3ae4ebb7c17decd4e97f1b35e155121ae77456eed6d529d7ec13195ba43a3bf8a3ad43724d11ae5512b
|
|
7
|
+
data.tar.gz: 06ea66fd5877fc58fc6e5a21805d434e53968004e8c00577264ca547b10ae7aa4f4196798a3fa18da332fd730cc8fe441ac8e37dbfe2527653fed0da598d8bbf
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{".":"0.
|
|
1
|
+
{".":"0.11.0","wasm/kobako-core":"0.5.0","wasm/kobako":"0.5.0","wasm/kobako-io":"0.5.0","wasm/kobako-regexp":"0.5.0","wasm/kobako-baker":"0.5.0"}
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.11.0](https://github.com/elct9620/kobako/compare/v0.10.0...v0.11.0) (2026-06-13)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **transport:** narrow guest-reachable methods via respond_to_guest? ([7a25fe3](https://github.com/elct9620/kobako/commit/7a25fe3b440b523b8a692b7601d08877b1305b0d))
|
|
9
|
+
|
|
10
|
+
## [0.10.0](https://github.com/elct9620/kobako/compare/v0.9.2...v0.10.0) (2026-06-12)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **catalog:** reject member binding after the seal (E-45) ([5193ed6](https://github.com/elct9620/kobako/commit/5193ed64100fa8ac05a2ba18cfa00634b4f40e6f))
|
|
16
|
+
* **guest:** bake the canonical boot state and instantiate per invocation (B-49) ([ee9ae6e](https://github.com/elct9620/kobako/commit/ee9ae6e09eab30f54dba0eeec00a5a2c80da819f))
|
|
17
|
+
* **pool:** add Kobako::Pool warm-Sandbox checkout (B-46..B-48) ([abf9bf8](https://github.com/elct9620/kobako/commit/abf9bf8d3c725c0ca0b8f2ab8b2ddd6f71ee6de4))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* **ext:** give the ABI probe a WASI context ([18e21ea](https://github.com/elct9620/kobako/commit/18e21eac8b160ade2724578aeacd86170403ee2c))
|
|
23
|
+
* **ext:** trust the artifact disk cache only in an exclusively writable directory ([17679cc](https://github.com/elct9620/kobako/commit/17679cc0d38d2a1b605e2faaeed762477a718c18))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Performance Improvements
|
|
27
|
+
|
|
28
|
+
* **bench:** re-bless the anchor onto the post-0.9.2 performance round ([195224d](https://github.com/elct9620/kobako/commit/195224d5d48e53dc2be17752e6d6af6382a0c1ec))
|
|
29
|
+
* **catalog:** drop the alloc-path block iteration from the gadget refusal ([542fe59](https://github.com/elct9620/kobako/commit/542fe59464bde57283d8e91984b82e82592bc3ab))
|
|
30
|
+
* **ext:** amortise module compilation across processes via .cwasm cache ([2e688bc](https://github.com/elct9620/kobako/commit/2e688bc4a1cdf1d0d4d5a0bce2efb314a5b8d1f7))
|
|
31
|
+
* **ext:** bound and harden the compiled-artifact cache ([949f222](https://github.com/elct9620/kobako/commit/949f2227af7cdf7d1913dcae58df683912a7dbd5))
|
|
32
|
+
* **ext:** cache ABI export handles and per-path InstancePre ([47573d0](https://github.com/elct9620/kobako/commit/47573d022233c788ce94413d1a2901ee9d62fc2e))
|
|
33
|
+
* **lib:** cache sealed frame encodings and cut decode-walk allocations ([e599573](https://github.com/elct9620/kobako/commit/e599573e37531b363baca83a1aa5833930100320))
|
|
34
|
+
|
|
3
35
|
## [0.9.2](https://github.com/elct9620/kobako/compare/v0.9.1...v0.9.2) (2026-06-11)
|
|
4
36
|
|
|
5
37
|
|
data/Cargo.lock
CHANGED
data/README.md
CHANGED
|
@@ -69,7 +69,7 @@ The script executes inside the Wasm guest. It cannot read your filesystem, open
|
|
|
69
69
|
|
|
70
70
|
### Services
|
|
71
71
|
|
|
72
|
-
Declare a Namespace, then `bind` any Ruby object as a Member; the guest reaches it as a `<Namespace>::<Member>` proxy and invokes its public methods through the Transport wire. See [`docs/behavior.md`](docs/behavior.md) B-07..B-12.
|
|
72
|
+
Declare a Namespace, then `bind` any Ruby object as a Member; the guest reaches it as a `<Namespace>::<Member>` proxy and invokes its public methods through the Transport wire. See [`docs/behavior/registration.md`](docs/behavior/registration.md) B-07..B-12.
|
|
73
73
|
|
|
74
74
|
```ruby
|
|
75
75
|
class User
|
|
@@ -93,7 +93,7 @@ Names must match `/\A[A-Z]\w*\z/`. Symbol kwargs travel transparently to the hos
|
|
|
93
93
|
|
|
94
94
|
### Output Capture
|
|
95
95
|
|
|
96
|
-
Guest writes through `puts` / `print` / `p` / `$stdout` / `$stderr` are buffered per-channel and exposed independently of the return value ([`docs/behavior.md`](docs/behavior.md) B-04). Buffers clear at the start of each invocation; overflow is clipped at the cap and flagged by `#stdout_truncated?` / `#stderr_truncated?`.
|
|
96
|
+
Guest writes through `puts` / `print` / `p` / `$stdout` / `$stderr` are buffered per-channel and exposed independently of the return value ([`docs/behavior/lifecycle.md`](docs/behavior/lifecycle.md) B-04). Buffers clear at the start of each invocation; overflow is clipped at the cap and flagged by `#stdout_truncated?` / `#stderr_truncated?`.
|
|
97
97
|
|
|
98
98
|
```ruby
|
|
99
99
|
result = sandbox.eval(<<~RUBY)
|
|
@@ -134,7 +134,7 @@ end
|
|
|
134
134
|
|
|
135
135
|
### Resource Limits
|
|
136
136
|
|
|
137
|
-
Each invocation enforces a wall-clock `timeout` and a per-invocation linear-memory `memory_limit`; exhaustion raises a `TrapError` subclass. Pass `nil` to `timeout` / `memory_limit` to disable that cap. Read [`Sandbox#usage`](lib/kobako/sandbox.rb) after the call — populated on every outcome including traps — for actual consumption ([`docs/behavior.md`](docs/behavior.md) B-35).
|
|
137
|
+
Each invocation enforces a wall-clock `timeout` and a per-invocation linear-memory `memory_limit`; exhaustion raises a `TrapError` subclass. Pass `nil` to `timeout` / `memory_limit` to disable that cap. Read [`Sandbox#usage`](lib/kobako/sandbox.rb) after the call — populated on every outcome including traps — for actual consumption ([`docs/behavior/lifecycle.md`](docs/behavior/lifecycle.md) B-35).
|
|
138
138
|
|
|
139
139
|
```ruby
|
|
140
140
|
sandbox = Kobako::Sandbox.new(
|
|
@@ -179,7 +179,8 @@ One Sandbox serves many invocations. Service bindings and preloaded snippets per
|
|
|
179
179
|
|
|
180
180
|
──────────────── invocation N ───────────────────
|
|
181
181
|
|
|
182
|
-
1.
|
|
182
|
+
1. start from the canonical boot state
|
|
183
|
+
(mruby pre-initialized into the artifact at build time)
|
|
183
184
|
|
|
184
185
|
2. replay snippets (in insertion order):
|
|
185
186
|
:Adder → defines Adder
|
|
@@ -189,7 +190,7 @@ One Sandbox serves many invocations. Service bindings and preloaded snippets per
|
|
|
189
190
|
|
|
190
191
|
4. return value to host
|
|
191
192
|
|
|
192
|
-
5. discard
|
|
193
|
+
5. discard the instance; reset per-invocation state:
|
|
193
194
|
· Handles invalidated
|
|
194
195
|
· stdout / stderr buffers cleared
|
|
195
196
|
· memory delta zeroed
|
|
@@ -199,9 +200,30 @@ One Sandbox serves many invocations. Service bindings and preloaded snippets per
|
|
|
199
200
|
|
|
200
201
|
For workloads that must be isolated from each other (one Sandbox per tenant, per student submission, per agent session), construct a fresh `Kobako::Sandbox` per scope — wasmtime's Engine and the compiled Module are cached at process scope, so additional Sandboxes amortize cold-start cost automatically.
|
|
201
202
|
|
|
203
|
+
### Pooling
|
|
204
|
+
|
|
205
|
+
For hosts that serve many short invocations, `Kobako::Pool` keeps a bounded set of warm, identically set-up Sandboxes and hands each one to a single exclusive holder at a time ([`docs/behavior/runtime.md`](docs/behavior/runtime.md) B-46..B-48). Construction forwards every `Sandbox.new` keyword verbatim; the optional block is the per-Sandbox setup window and runs exactly once per constructed Sandbox.
|
|
206
|
+
|
|
207
|
+
`Kobako::Pool` is experimental today and is best treated as a convenience for warm, pre-configured reuse rather than a throughput optimisation. B-49 bakes the shared boot state into the artifact and every dynamic script still compiles and runs per invocation, so all a pool actually saves is the ~30 µs host-side `Sandbox.new`. For the workload kobako is built for — many small, short-lived Sandboxes running dynamic scripts — that is not a significant gain (~4-5% in the [serverless example](examples/serverless/README.md), and proportionally less once the script itself does real work).
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
pool = Kobako::Pool.new(slots: 4) do |sandbox|
|
|
211
|
+
sandbox.define(:KV).bind(:Lookup, ->(key) { redis.get(key) })
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
pool.with { |sandbox| sandbox.eval(%(KV::Lookup.call("user_42"))) }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
| Option | Meaning | Default |
|
|
218
|
+
|--------|---------|---------|
|
|
219
|
+
| `slots:` | Upper bound on constructed Sandboxes | required |
|
|
220
|
+
| `checkout_timeout:` | Seconds `#with` waits for a free Sandbox; `nil` waits indefinitely | 5.0 |
|
|
221
|
+
|
|
222
|
+
Sandboxes construct lazily on first demand. `#with` yields a Sandbox with empty output buffers and returns the block's value; at block exit the Sandbox returns to the pool, except a block that raises `Kobako::TrapError` discards its Sandbox and the slot refills by a fresh construction on next demand. A checkout that waits past `checkout_timeout` raises `Kobako::PoolTimeoutError`. There is no teardown verb — a Pool releases everything with its own reachability.
|
|
223
|
+
|
|
202
224
|
### Service Blocks
|
|
203
225
|
|
|
204
|
-
A Service method can accept a guest-supplied block via `&blk` and `yield` into it. The block body runs inside the Wasm guest; `break` / `next` / exceptions follow normal Ruby semantics, scoped to the single dispatch. See [`docs/behavior.md`](docs/behavior.md) B-23..B-30.
|
|
226
|
+
A Service method can accept a guest-supplied block via `&blk` and `yield` into it. The block body runs inside the Wasm guest; `break` / `next` / exceptions follow normal Ruby semantics, scoped to the single dispatch. See [`docs/behavior/yield.md`](docs/behavior/yield.md) B-23..B-30.
|
|
205
227
|
|
|
206
228
|
```ruby
|
|
207
229
|
sandbox.define(:Seq).bind(:Map, ->(items, &blk) { items.map(&blk) })
|
|
@@ -212,7 +234,7 @@ sandbox.eval('Seq::Map.call([1, 2, 3]) { |x| x * 2 }')
|
|
|
212
234
|
|
|
213
235
|
### Handle Management
|
|
214
236
|
|
|
215
|
-
A non-wire-representable host object — returned from a Service (B-14), passed to `#run` (B-34), or handed back from the guest (B-37) — crosses the boundary as an opaque `Kobako::Handle` proxy and is restored to the original object before host code sees it; any other unrepresentable value raises `Kobako::SandboxError`. Handles are scoped to a single invocation ([`docs/behavior.md`](docs/behavior.md) B-13..B-21, B-34, B-37).
|
|
237
|
+
A non-wire-representable host object — returned from a Service (B-14), passed to `#run` (B-34), or handed back from the guest (B-37) — crosses the boundary as an opaque `Kobako::Handle` proxy and is restored to the original object before host code sees it; any other unrepresentable value raises `Kobako::SandboxError`. Handles are scoped to a single invocation ([`docs/behavior/dispatch.md`](docs/behavior/dispatch.md) B-13..B-21, B-34, B-37).
|
|
216
238
|
|
|
217
239
|
```ruby
|
|
218
240
|
class Greeter
|
|
@@ -248,7 +270,7 @@ This is deliberate, not a leak. Handle IDs run to 2³¹ − 1 per invocation and
|
|
|
248
270
|
|
|
249
271
|
### Snippets & Entrypoints
|
|
250
272
|
|
|
251
|
-
`Sandbox#preload` registers named mruby snippets that replay
|
|
273
|
+
`Sandbox#preload` registers named mruby snippets that replay into every invocation's canonical boot state; `Sandbox#run(:Target, *args, **kwargs)` dispatches into a top-level `Object` constant defined by those snippets ([`docs/behavior/invocation.md`](docs/behavior/invocation.md) B-31..B-33).
|
|
252
274
|
|
|
253
275
|
```ruby
|
|
254
276
|
sandbox = Kobako::Sandbox.new
|
|
@@ -262,7 +284,7 @@ sandbox.run(:Greeter, name: "world") # => "hello, world"
|
|
|
262
284
|
```
|
|
263
285
|
per-invocation replay (every #eval / #run, snippets in insertion order):
|
|
264
286
|
|
|
265
|
-
|
|
287
|
+
canonical boot state
|
|
266
288
|
│
|
|
267
289
|
├──▶ replay :Adder (defines Adder)
|
|
268
290
|
│
|
|
@@ -271,7 +293,7 @@ sandbox.run(:Greeter, name: "world") # => "hello, world"
|
|
|
271
293
|
└──▶ eval(source) -or- run(:Target, *args, **kwargs)
|
|
272
294
|
│
|
|
273
295
|
▼
|
|
274
|
-
return value, then
|
|
296
|
+
return value, then instance discarded
|
|
275
297
|
```
|
|
276
298
|
|
|
277
299
|
`#preload` accepts two payload forms:
|
|
@@ -300,6 +322,11 @@ sandbox.define(:Cfg).bind(:Settings, ThemeReader.new) # not: bind(:Settings, Ap
|
|
|
300
322
|
sandbox.eval('Cfg::Settings.color') # => "#3366ff" — every other method raises NoMethodError
|
|
301
323
|
```
|
|
302
324
|
|
|
325
|
+
When a purpose-built wrapper is more than you need, an object can gate its own surface in
|
|
326
|
+
place: a private `respond_to_guest?(name)` answers, per method, whether the guest may call
|
|
327
|
+
it. Returning `false` for every name makes the object opaque — a credential the guest
|
|
328
|
+
forwards to another Service but never reads — while a named subset becomes an allow-list.
|
|
329
|
+
|
|
303
330
|
Guest code can name any `<Namespace>::<Member>` path, but a forged name only resolves to
|
|
304
331
|
something you bound — the real authorization gate is this host-side allowlist. Give each
|
|
305
332
|
trust context its own Sandbox, and see [`docs/security.md`](docs/security.md) for the rest
|
|
@@ -312,15 +339,16 @@ Order-of-magnitude figures on macOS arm64, Ruby 3.4.7, YJIT off. Absolute values
|
|
|
312
339
|
|
|
313
340
|
| Phase | Cost |
|
|
314
341
|
|--------------------------------------------------------------|-----------------------|
|
|
315
|
-
| First `Sandbox.new`
|
|
316
|
-
|
|
|
317
|
-
|
|
|
318
|
-
| Warm `#
|
|
319
|
-
|
|
|
320
|
-
|
|
|
321
|
-
|
|
|
322
|
-
|
|
323
|
-
|
|
342
|
+
| First `Sandbox.new` ever for a Guest Binary (Module JIT, then disk-cached) | ~500 ms once per machine |
|
|
343
|
+
| First `Sandbox.new` in a fresh process (`.cwasm` cache warm) | ~5 ms one-time |
|
|
344
|
+
| Subsequent `Sandbox.new` (caches warm) | ~30 µs |
|
|
345
|
+
| Warm `#eval("nil")` on a reused Sandbox | ~73 µs |
|
|
346
|
+
| Warm `#run(:Entrypoint, ...)` dispatch | ~104 µs |
|
|
347
|
+
| Service call amortized inside one invocation | ~6.8 µs |
|
|
348
|
+
| Snippet replay per invocation | ~8 µs each |
|
|
349
|
+
| Per additional idle Sandbox (RSS) | ~1 KB |
|
|
350
|
+
|
|
351
|
+
The Cranelift JIT runs once per machine and gem version — the compiled artifact persists in a `.cwasm` disk cache, so later processes deserialize in milliseconds. An idle Sandbox holds no wasm instance (the canonical boot state is baked into the artifact and instantiated per invocation), which is why a thousand idle tenants cost ~32 MB total. `ext/` does not release the GVL during wasmtime execution, so wasm work is GVL-serialized: aggregate throughput stays around 16k `#eval`/s regardless of Thread count, though Ruby-side `#eval` setup still overlaps. A +10% regression on any of the six SPEC-mandated benchmarks blocks release.
|
|
324
352
|
|
|
325
353
|
Regexp is an opt-in capability gem, excluded from the default binary and the gated set; its throughput is tracked in a separate non-gated characterization (`#10` in [`benchmark/README.md`](benchmark/README.md)). There `=~` (~5 µs/match) costs about 4× `match?` (~1.2 µs), because `=~` eagerly builds the `MatchData` and match globals — prefer `match?` for boolean tests.
|
|
326
354
|
|
data/data/kobako.wasm
CHANGED
|
Binary file
|
data/ext/kobako/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "kobako"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["Aotokitsuruya <contact@aotoki.me>"]
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -27,8 +27,18 @@ wasmtime = { version = "45.0.0", default-features = false, features = [
|
|
|
27
27
|
"wat",
|
|
28
28
|
] }
|
|
29
29
|
# wasmtime-wasi provides WASI preview1 support for routing guest stdout/stderr
|
|
30
|
-
# into in-memory buffers
|
|
30
|
+
# into in-memory buffers. The `p1` feature enables the
|
|
31
31
|
# WasiCtxBuilder + preview1 adapter which wires fd 1/2 to pipes. We omit
|
|
32
32
|
# `p2` (component-model) and `p0`/`p3` (async) because kobako runs
|
|
33
33
|
# synchronous sandboxes only.
|
|
34
34
|
wasmtime-wasi = { version = "45.0.0", default-features = false, features = ["p1"] }
|
|
35
|
+
# sha2 keys the on-disk compiled-module cache by Guest Binary content
|
|
36
|
+
# (see runtime/cache.rs); a collision would load the wrong artifact, so
|
|
37
|
+
# the hash must be cryptographic.
|
|
38
|
+
sha2 = "0.10"
|
|
39
|
+
|
|
40
|
+
# libc supplies geteuid for the cache-directory ownership check gating
|
|
41
|
+
# the unsafe artifact deserialize (see runtime/cache.rs); std exposes a
|
|
42
|
+
# file's owner but not the process's effective uid.
|
|
43
|
+
[target.'cfg(unix)'.dependencies]
|
|
44
|
+
libc = "0.2"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//!
|
|
12
12
|
//! The host wall-clock cap is unaffected: the per-invocation timeout runs on
|
|
13
13
|
//! wasmtime epoch interruption against a host `Instant`, never the guest's
|
|
14
|
-
//! frozen `wasi:clocks/monotonic-clock
|
|
14
|
+
//! frozen `wasi:clocks/monotonic-clock`.
|
|
15
15
|
|
|
16
16
|
use std::time::Duration;
|
|
17
17
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//! Process-wide caches for the wasmtime `Engine` and compiled
|
|
2
|
-
//! `Module
|
|
2
|
+
//! `Module`, plus the on-disk compiled-artifact cache.
|
|
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
|
|
@@ -9,19 +9,26 @@
|
|
|
9
9
|
//! Ruby callers, who construct a `Runtime` via
|
|
10
10
|
//! `Kobako::Runtime.from_path(...)` and never see Engine or Module.
|
|
11
11
|
//!
|
|
12
|
+
//! Across processes, the Cranelift compile cost is amortised by a
|
|
13
|
+
//! best-effort `.cwasm` disk cache keyed by the SHA-256 of the Guest
|
|
14
|
+
//! Binary bytes; every cache failure falls
|
|
15
|
+
//! back to in-process compilation.
|
|
16
|
+
//!
|
|
12
17
|
//! Concurrency: under Ruby's GVL only one thread can execute Rust code
|
|
13
18
|
//! at a time, so the Mutex is held briefly during HashMap insert/lookup
|
|
14
19
|
//! and serves to satisfy `Sync` bounds rather than to arbitrate real
|
|
15
20
|
//! contention.
|
|
16
21
|
|
|
17
22
|
use std::collections::HashMap;
|
|
23
|
+
use std::fmt::Write as _;
|
|
18
24
|
use std::fs;
|
|
19
25
|
use std::path::{Path, PathBuf};
|
|
20
26
|
use std::sync::{Mutex, OnceLock};
|
|
21
27
|
use std::thread;
|
|
22
|
-
use std::time::Duration;
|
|
28
|
+
use std::time::{Duration, SystemTime};
|
|
23
29
|
|
|
24
30
|
use magnus::{Error as MagnusError, Ruby};
|
|
31
|
+
use sha2::{Digest, Sha256};
|
|
25
32
|
use wasmtime::{Config as WtConfig, Engine as WtEngine, Module as WtModule};
|
|
26
33
|
|
|
27
34
|
use super::{setup_err, MODULE_NOT_BUILT_ERROR};
|
|
@@ -30,7 +37,7 @@ static SHARED_ENGINE: OnceLock<WtEngine> = OnceLock::new();
|
|
|
30
37
|
static MODULE_CACHE: OnceLock<Mutex<HashMap<PathBuf, WtModule>>> = OnceLock::new();
|
|
31
38
|
|
|
32
39
|
/// Ticker cadence for the process-singleton epoch ticker. Bounds the
|
|
33
|
-
/// granularity of the
|
|
40
|
+
/// granularity of the wall-clock timeout: the
|
|
34
41
|
/// `epoch_deadline_callback` fires once per tick (`Continue(1)`), so the
|
|
35
42
|
/// trap can lag the deadline by at most one tick under nominal
|
|
36
43
|
/// scheduling. 10 ms keeps the lag small enough that it does not skew
|
|
@@ -50,7 +57,7 @@ const EPOCH_TICK: Duration = Duration::from_millis(10);
|
|
|
50
57
|
///
|
|
51
58
|
/// Also enables `epoch_interruption(true)` so every Store can install an
|
|
52
59
|
/// `epoch_deadline_callback` for the per-run wall-clock cap
|
|
53
|
-
///
|
|
60
|
+
/// cap. The first call spawns the process-singleton ticker
|
|
54
61
|
/// thread that drives `engine.increment_epoch()` at `EPOCH_TICK`
|
|
55
62
|
/// cadence; subsequent calls reuse the same engine and ticker.
|
|
56
63
|
pub(crate) fn shared_engine() -> Result<&'static WtEngine, MagnusError> {
|
|
@@ -124,11 +131,168 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, MagnusError> {
|
|
|
124
131
|
),
|
|
125
132
|
)
|
|
126
133
|
})?;
|
|
127
|
-
let
|
|
128
|
-
|
|
134
|
+
let engine = shared_engine()?;
|
|
135
|
+
let artifact = artifact_path(&bytes);
|
|
136
|
+
let module = match artifact.as_deref().and_then(|p| load_artifact(engine, p)) {
|
|
137
|
+
Some(module) => module,
|
|
138
|
+
None => {
|
|
139
|
+
let module = WtModule::new(engine, &bytes).map_err(|e| {
|
|
140
|
+
setup_err(&ruby, format!("failed to compile Sandbox runtime: {}", e))
|
|
141
|
+
})?;
|
|
142
|
+
if let Some(p) = artifact.as_deref() {
|
|
143
|
+
store_artifact(&module, p);
|
|
144
|
+
}
|
|
145
|
+
module
|
|
146
|
+
}
|
|
147
|
+
};
|
|
129
148
|
cache
|
|
130
149
|
.lock()
|
|
131
150
|
.expect("module cache mutex poisoned")
|
|
132
151
|
.insert(path.to_path_buf(), module.clone());
|
|
133
152
|
Ok(module)
|
|
134
153
|
}
|
|
154
|
+
|
|
155
|
+
/// Retention window for unused cache entries. A hit refreshes the
|
|
156
|
+
/// artifact's mtime, so only entries no process has loaded for the
|
|
157
|
+
/// whole window are removed by `prune_stale`.
|
|
158
|
+
const ARTIFACT_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
|
159
|
+
|
|
160
|
+
/// Compute the disk-cache location for a Guest Binary's compiled
|
|
161
|
+
/// artifact: `$XDG_CACHE_HOME/kobako` (falling back to
|
|
162
|
+
/// `~/.cache/kobako`) `/<sha256 of the wasm bytes>-<gem version>.cwasm`.
|
|
163
|
+
/// Content addressing makes a rebuilt Guest Binary a new cache entry
|
|
164
|
+
/// rather than an invalidation problem; the gem-version segment keeps
|
|
165
|
+
/// two installed kobako versions (each pinning its own wasmtime) from
|
|
166
|
+
/// sharing a key and recompile-thrashing each other's entry. wasmtime
|
|
167
|
+
/// itself rejects an artifact produced by an incompatible wasmtime
|
|
168
|
+
/// version or Config at deserialize time. Returns `None` when no home
|
|
169
|
+
/// directory is available — the caller then just compiles in-process.
|
|
170
|
+
fn artifact_path(wasm_bytes: &[u8]) -> Option<PathBuf> {
|
|
171
|
+
let base = std::env::var_os("XDG_CACHE_HOME")
|
|
172
|
+
.map(PathBuf::from)
|
|
173
|
+
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache")))?;
|
|
174
|
+
let digest = Sha256::digest(wasm_bytes);
|
|
175
|
+
let mut name = String::with_capacity(80);
|
|
176
|
+
for byte in digest {
|
|
177
|
+
let _ = write!(name, "{:02x}", byte);
|
|
178
|
+
}
|
|
179
|
+
let _ = write!(name, "-{}.cwasm", env!("CARGO_PKG_VERSION"));
|
|
180
|
+
Some(base.join("kobako").join(name))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// Best-effort load of a previously serialized compiled artifact.
|
|
184
|
+
/// Any failure — absent file, truncated bytes, wasmtime version or
|
|
185
|
+
/// Config mismatch — returns `None` and the caller recompiles. A hit
|
|
186
|
+
/// refreshes the file's mtime so `prune_stale`'s retention window
|
|
187
|
+
/// measures time since last use, not since creation.
|
|
188
|
+
fn load_artifact(engine: &WtEngine, artifact: &Path) -> Option<WtModule> {
|
|
189
|
+
if !artifact.exists() || !artifact.parent().is_some_and(dir_is_private) {
|
|
190
|
+
return None;
|
|
191
|
+
}
|
|
192
|
+
// SAFETY: `Module::deserialize_file` trusts the artifact bytes.
|
|
193
|
+
// `dir_is_private` just verified the cache directory is owned by
|
|
194
|
+
// the current user and writable by no one else, so only files this
|
|
195
|
+
// module wrote are loaded, addressed by the content hash of the
|
|
196
|
+
// Guest Binary being constructed — the artifact carries exactly
|
|
197
|
+
// the trust of `data/kobako.wasm`.
|
|
198
|
+
let module = unsafe { WtModule::deserialize_file(engine, artifact) }.ok()?;
|
|
199
|
+
let _ = fs::File::options()
|
|
200
|
+
.append(true)
|
|
201
|
+
.open(artifact)
|
|
202
|
+
.and_then(|f| f.set_modified(SystemTime::now()));
|
|
203
|
+
Some(module)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// Best-effort write of a freshly compiled artifact. The temp-file +
|
|
207
|
+
/// rename pair keeps concurrent processes from observing a partial
|
|
208
|
+
/// write; every failure is swallowed because the cache is purely an
|
|
209
|
+
/// optimisation. A successful write also triggers `prune_stale` so the
|
|
210
|
+
/// cache directory cannot grow without bound across Guest Binary
|
|
211
|
+
/// rebuilds.
|
|
212
|
+
fn store_artifact(module: &WtModule, artifact: &Path) {
|
|
213
|
+
let Ok(bytes) = module.serialize() else {
|
|
214
|
+
return;
|
|
215
|
+
};
|
|
216
|
+
let Some(dir) = artifact.parent() else { return };
|
|
217
|
+
if create_cache_dir(dir).is_err() || !dir_is_private(dir) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
let tmp = artifact.with_extension(format!("tmp{}", std::process::id()));
|
|
221
|
+
if fs::write(&tmp, bytes).is_err() {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if fs::rename(&tmp, artifact).is_ok() {
|
|
225
|
+
prune_stale(dir, artifact);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// Create the cache directory owner-only (`0700`) on Unix so no other
|
|
230
|
+
/// local user can plant an artifact the unsafe deserialize would
|
|
231
|
+
/// trust; elsewhere fall back to default permissions.
|
|
232
|
+
#[cfg(unix)]
|
|
233
|
+
fn create_cache_dir(dir: &Path) -> std::io::Result<()> {
|
|
234
|
+
use std::os::unix::fs::DirBuilderExt;
|
|
235
|
+
fs::DirBuilder::new()
|
|
236
|
+
.recursive(true)
|
|
237
|
+
.mode(0o700)
|
|
238
|
+
.create(dir)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[cfg(not(unix))]
|
|
242
|
+
fn create_cache_dir(dir: &Path) -> std::io::Result<()> {
|
|
243
|
+
fs::create_dir_all(dir)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/// Returns whether the cache directory upholds the trust the unsafe
|
|
247
|
+
/// deserialize relies on: owned by the current effective user and
|
|
248
|
+
/// writable by no one else. A pre-existing directory another user owns
|
|
249
|
+
/// or can write to — e.g. under a shared `XDG_CACHE_HOME` — fails here
|
|
250
|
+
/// and both disk-cache tiers are skipped.
|
|
251
|
+
#[cfg(unix)]
|
|
252
|
+
fn dir_is_private(dir: &Path) -> bool {
|
|
253
|
+
use std::os::unix::fs::MetadataExt;
|
|
254
|
+
let Ok(meta) = fs::metadata(dir) else {
|
|
255
|
+
return false;
|
|
256
|
+
};
|
|
257
|
+
// SAFETY: `geteuid` reads process state and has no preconditions.
|
|
258
|
+
meta.uid() == unsafe { libc::geteuid() } && meta.mode() & 0o022 == 0
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#[cfg(not(unix))]
|
|
262
|
+
fn dir_is_private(_dir: &Path) -> bool {
|
|
263
|
+
true
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/// Remove every cache entry (`.cwasm` artifacts and crash-leftover
|
|
267
|
+
/// `.tmp*` files) whose mtime sits past `ARTIFACT_TTL`, except the
|
|
268
|
+
/// just-written `keep`. Live temp files are seconds old and never
|
|
269
|
+
/// qualify; foreign file names are left untouched.
|
|
270
|
+
fn prune_stale(dir: &Path, keep: &Path) {
|
|
271
|
+
let Ok(entries) = fs::read_dir(dir) else {
|
|
272
|
+
return;
|
|
273
|
+
};
|
|
274
|
+
for entry in entries.flatten() {
|
|
275
|
+
let path = entry.path();
|
|
276
|
+
if path == keep || !cache_entry_name(&path) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
let stale = entry
|
|
280
|
+
.metadata()
|
|
281
|
+
.and_then(|meta| meta.modified())
|
|
282
|
+
.ok()
|
|
283
|
+
.and_then(|mtime| mtime.elapsed().ok())
|
|
284
|
+
.is_some_and(|age| age > ARTIFACT_TTL);
|
|
285
|
+
if stale {
|
|
286
|
+
let _ = fs::remove_file(&path);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// Returns whether `path` carries a file name this cache wrote — a
|
|
292
|
+
/// `.cwasm` artifact or a `.tmp*` leftover.
|
|
293
|
+
fn cache_entry_name(path: &Path) -> bool {
|
|
294
|
+
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
295
|
+
return false;
|
|
296
|
+
};
|
|
297
|
+
name.ends_with(".cwasm") || name.contains(".tmp")
|
|
298
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//! Per-channel stdout / stderr capture sizing and clipping.
|
|
2
2
|
//!
|
|
3
|
-
//! Two pure helpers shared by the run path
|
|
3
|
+
//! Two pure helpers shared by the run path: one
|
|
4
4
|
//! sizes the per-run `MemoryOutputPipe`, the other clips a captured
|
|
5
5
|
//! snapshot back to the configured cap and reports whether the cap was
|
|
6
6
|
//! exceeded. Kept channel-agnostic (a function of `cap`, not of which
|
|
@@ -13,11 +13,10 @@ use std::time::Duration;
|
|
|
13
13
|
/// Wall-clock and output caps for one `Runtime`. `None` on any field
|
|
14
14
|
/// disables that cap.
|
|
15
15
|
pub(crate) struct Config {
|
|
16
|
-
/// Wall-clock cap for one guest `#eval` / `#run
|
|
17
|
-
///
|
|
18
|
-
/// `Runtime::prime_caps`.
|
|
16
|
+
/// Wall-clock cap for one guest `#eval` / `#run`. Stamped into a
|
|
17
|
+
/// per-run `Instant` deadline by `Runtime::prime_caps`.
|
|
19
18
|
pub(crate) timeout: Option<Duration>,
|
|
20
|
-
/// Byte cap for guest stdout capture
|
|
19
|
+
/// Byte cap for guest stdout capture.
|
|
21
20
|
/// Sizes the per-run `MemoryOutputPipe` and computes the truncation
|
|
22
21
|
/// flag in `Runtime::build_snapshot`.
|
|
23
22
|
pub(crate) stdout_limit_bytes: Option<usize>,
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
//!
|
|
3
3
|
//! When the guest invokes the wasm import declared in
|
|
4
4
|
//! `wasm/kobako-core/src/abi.rs`, wasmtime calls back into the host
|
|
5
|
-
//! through the closure
|
|
6
|
-
//! That closure delegates here. The dispatcher
|
|
5
|
+
//! through the closure registered by `instance_pre::build_linker`.
|
|
6
|
+
//! That closure delegates here. The dispatcher:
|
|
7
7
|
//!
|
|
8
8
|
//! 1. Reads the Request bytes from guest linear memory.
|
|
9
9
|
//! 2. Invokes the Ruby-side dispatch Proc bound via
|
|
@@ -55,8 +55,8 @@ use wasmtime::Caller;
|
|
|
55
55
|
use super::invocation::Invocation;
|
|
56
56
|
|
|
57
57
|
// ============================================================
|
|
58
|
-
// Active-caller pointer for the per-thread Invocation slot
|
|
59
|
-
// SPEC.md Single-Invocation Slot).
|
|
58
|
+
// Active-caller pointer for the per-thread Invocation slot
|
|
59
|
+
// (SPEC.md Single-Invocation Slot).
|
|
60
60
|
// ============================================================
|
|
61
61
|
//
|
|
62
62
|
// `Runtime#yield_to_active_invocation` (whose body is the
|
|
@@ -73,8 +73,8 @@ use super::invocation::Invocation;
|
|
|
73
73
|
// The pointer is therefore erased to `NonNull<()>` and parked in a
|
|
74
74
|
// per-thread slot — the materialised form of the SPEC.md
|
|
75
75
|
// "Single-Invocation Slot" invariant. The single-threaded wasm
|
|
76
|
-
// execution per Sandbox
|
|
77
|
-
// dispatch frames
|
|
76
|
+
// execution per Sandbox plus the LIFO re-entry shape of nested
|
|
77
|
+
// dispatch frames ensures no aliasing across threads or across
|
|
78
78
|
// frames; the recovery invariant lives at `current_caller`. The
|
|
79
79
|
// pointer is set on entry to `handle` and restored to the outer
|
|
80
80
|
// frame's value on every exit through a drop guard.
|
|
@@ -85,7 +85,7 @@ thread_local! {
|
|
|
85
85
|
|
|
86
86
|
/// RAII guard that saves the previous `ACTIVE_CALLER` value on
|
|
87
87
|
/// installation and restores it on drop. Nested `__kobako_dispatch`
|
|
88
|
-
/// frames stack within one Invocation
|
|
88
|
+
/// frames stack within one Invocation — the inner frame's `set`
|
|
89
89
|
/// swaps in its own pointer while remembering the outer's; drop
|
|
90
90
|
/// restores the outer so its continuation (e.g. iterating over another
|
|
91
91
|
/// guest block) still finds a live caller.
|
|
@@ -129,7 +129,7 @@ pub(crate) fn current_caller<'a>() -> Option<&'a mut Caller<'a, Invocation>> {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
/// Drive a single `__kobako_dispatch` invocation end-to-end. Entry point
|
|
132
|
-
/// from the wasmtime closure
|
|
132
|
+
/// from the wasmtime closure registered by `instance_pre::build_linker`.
|
|
133
133
|
///
|
|
134
134
|
/// Returns the packed `(ptr<<32)|len` u64 on success, 0 on any
|
|
135
135
|
/// wire-layer fault. Failure paths log a `[kobako-dispatch]` line to
|