kobako 0.9.2 → 0.10.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 +25 -0
- data/Cargo.lock +3 -1
- data/README.md +35 -14
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +11 -1
- data/ext/kobako/src/runtime/cache.rs +168 -4
- data/ext/kobako/src/runtime/dispatch.rs +2 -2
- 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 +12 -69
- data/ext/kobako/src/runtime.rs +371 -384
- data/lib/kobako/catalog/handles.rb +9 -13
- data/lib/kobako/catalog/namespaces.rb +22 -3
- data/lib/kobako/catalog/snippets.rb +9 -1
- data/lib/kobako/codec/decoder.rb +5 -1
- data/lib/kobako/errors.rb +11 -1
- data/lib/kobako/namespace.rb +16 -2
- data/lib/kobako/pool.rb +182 -0
- data/lib/kobako/sandbox.rb +16 -14
- 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
- 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: f94d5012ea43dae1d5bd51d2b8f0f6c7357edc259c7771577d3454c7055a5f1d
|
|
4
|
+
data.tar.gz: 494087c5769aa0ce019ac47d705a1426fb237840ab3c88700afdcc970816b43d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9cd941922d385068bcb6e78849f5d43dc68e8bc36e2a6a04b245e406c488a2313cffc697441f69c2f76f3ea154924c67e594271009308702aa4f72c514bc0983
|
|
7
|
+
data.tar.gz: 5a3b07b25b6ea434f69731749c5756d802aa585d2f10cdb3097dafdaf442d1d1da61a1c338cbb0eb3506915d2ba968baf7906607ec97059f1da5ac1070cd6950
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{".":"0.
|
|
1
|
+
{".":"0.10.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,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.10.0](https://github.com/elct9620/kobako/compare/v0.9.2...v0.10.0) (2026-06-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **catalog:** reject member binding after the seal (E-45) ([5193ed6](https://github.com/elct9620/kobako/commit/5193ed64100fa8ac05a2ba18cfa00634b4f40e6f))
|
|
9
|
+
* **guest:** bake the canonical boot state and instantiate per invocation (B-49) ([ee9ae6e](https://github.com/elct9620/kobako/commit/ee9ae6e09eab30f54dba0eeec00a5a2c80da819f))
|
|
10
|
+
* **pool:** add Kobako::Pool warm-Sandbox checkout (B-46..B-48) ([abf9bf8](https://github.com/elct9620/kobako/commit/abf9bf8d3c725c0ca0b8f2ab8b2ddd6f71ee6de4))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **ext:** give the ABI probe a WASI context ([18e21ea](https://github.com/elct9620/kobako/commit/18e21eac8b160ade2724578aeacd86170403ee2c))
|
|
16
|
+
* **ext:** trust the artifact disk cache only in an exclusively writable directory ([17679cc](https://github.com/elct9620/kobako/commit/17679cc0d38d2a1b605e2faaeed762477a718c18))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Performance Improvements
|
|
20
|
+
|
|
21
|
+
* **bench:** re-bless the anchor onto the post-0.9.2 performance round ([195224d](https://github.com/elct9620/kobako/commit/195224d5d48e53dc2be17752e6d6af6382a0c1ec))
|
|
22
|
+
* **catalog:** drop the alloc-path block iteration from the gadget refusal ([542fe59](https://github.com/elct9620/kobako/commit/542fe59464bde57283d8e91984b82e82592bc3ab))
|
|
23
|
+
* **ext:** amortise module compilation across processes via .cwasm cache ([2e688bc](https://github.com/elct9620/kobako/commit/2e688bc4a1cdf1d0d4d5a0bce2efb314a5b8d1f7))
|
|
24
|
+
* **ext:** bound and harden the compiled-artifact cache ([949f222](https://github.com/elct9620/kobako/commit/949f2227af7cdf7d1913dcae58df683912a7dbd5))
|
|
25
|
+
* **ext:** cache ABI export handles and per-path InstancePre ([47573d0](https://github.com/elct9620/kobako/commit/47573d022233c788ce94413d1a2901ee9d62fc2e))
|
|
26
|
+
* **lib:** cache sealed frame encodings and cut decode-walk allocations ([e599573](https://github.com/elct9620/kobako/commit/e599573e37531b363baca83a1aa5833930100320))
|
|
27
|
+
|
|
3
28
|
## [0.9.2](https://github.com/elct9620/kobako/compare/v0.9.1...v0.9.2) (2026-06-11)
|
|
4
29
|
|
|
5
30
|
|
data/Cargo.lock
CHANGED
data/README.md
CHANGED
|
@@ -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,6 +200,25 @@ 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.md`](docs/behavior.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
|
+
```ruby
|
|
208
|
+
pool = Kobako::Pool.new(slots: 4) do |sandbox|
|
|
209
|
+
sandbox.define(:KV).bind(:Lookup, ->(key) { redis.get(key) })
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
pool.with { |sandbox| sandbox.eval(%(KV::Lookup.call("user_42"))) }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
| Option | Meaning | Default |
|
|
216
|
+
|--------|---------|---------|
|
|
217
|
+
| `slots:` | Upper bound on constructed Sandboxes | required |
|
|
218
|
+
| `checkout_timeout:` | Seconds `#with` waits for a free Sandbox; `nil` waits indefinitely | 5.0 |
|
|
219
|
+
|
|
220
|
+
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.
|
|
221
|
+
|
|
202
222
|
### Service Blocks
|
|
203
223
|
|
|
204
224
|
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.
|
|
@@ -248,7 +268,7 @@ This is deliberate, not a leak. Handle IDs run to 2³¹ − 1 per invocation and
|
|
|
248
268
|
|
|
249
269
|
### Snippets & Entrypoints
|
|
250
270
|
|
|
251
|
-
`Sandbox#preload` registers named mruby snippets that replay
|
|
271
|
+
`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.md`](docs/behavior.md) B-31..B-33).
|
|
252
272
|
|
|
253
273
|
```ruby
|
|
254
274
|
sandbox = Kobako::Sandbox.new
|
|
@@ -262,7 +282,7 @@ sandbox.run(:Greeter, name: "world") # => "hello, world"
|
|
|
262
282
|
```
|
|
263
283
|
per-invocation replay (every #eval / #run, snippets in insertion order):
|
|
264
284
|
|
|
265
|
-
|
|
285
|
+
canonical boot state
|
|
266
286
|
│
|
|
267
287
|
├──▶ replay :Adder (defines Adder)
|
|
268
288
|
│
|
|
@@ -271,7 +291,7 @@ sandbox.run(:Greeter, name: "world") # => "hello, world"
|
|
|
271
291
|
└──▶ eval(source) -or- run(:Target, *args, **kwargs)
|
|
272
292
|
│
|
|
273
293
|
▼
|
|
274
|
-
return value, then
|
|
294
|
+
return value, then instance discarded
|
|
275
295
|
```
|
|
276
296
|
|
|
277
297
|
`#preload` accepts two payload forms:
|
|
@@ -312,15 +332,16 @@ Order-of-magnitude figures on macOS arm64, Ruby 3.4.7, YJIT off. Absolute values
|
|
|
312
332
|
|
|
313
333
|
| Phase | Cost |
|
|
314
334
|
|--------------------------------------------------------------|-----------------------|
|
|
315
|
-
| First `Sandbox.new`
|
|
316
|
-
|
|
|
317
|
-
|
|
|
318
|
-
| Warm `#
|
|
319
|
-
|
|
|
320
|
-
|
|
|
321
|
-
|
|
|
322
|
-
|
|
323
|
-
|
|
335
|
+
| First `Sandbox.new` ever for a Guest Binary (Module JIT, then disk-cached) | ~500 ms once per machine |
|
|
336
|
+
| First `Sandbox.new` in a fresh process (`.cwasm` cache warm) | ~5 ms one-time |
|
|
337
|
+
| Subsequent `Sandbox.new` (caches warm) | ~30 µs |
|
|
338
|
+
| Warm `#eval("nil")` on a reused Sandbox | ~73 µs |
|
|
339
|
+
| Warm `#run(:Entrypoint, ...)` dispatch | ~104 µs |
|
|
340
|
+
| Service call amortized inside one invocation | ~6.8 µs |
|
|
341
|
+
| Snippet replay per invocation | ~8 µs each |
|
|
342
|
+
| Per additional idle Sandbox (RSS) | ~1 KB |
|
|
343
|
+
|
|
344
|
+
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
345
|
|
|
325
346
|
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
347
|
|
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.10.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["Aotokitsuruya <contact@aotoki.me>"]
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -32,3 +32,13 @@ wasmtime = { version = "45.0.0", default-features = false, features = [
|
|
|
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"
|
|
@@ -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 (docs/behavior.md B-01); 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};
|
|
@@ -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
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
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
|
|
5
|
+
//! through the closure registered by `instance_pre::build_linker`.
|
|
6
6
|
//! That closure delegates here. The dispatcher (docs/behavior.md B-12 / B-13):
|
|
7
7
|
//!
|
|
8
8
|
//! 1. Reads the Request bytes from guest linear memory.
|
|
@@ -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
|
|
@@ -1,41 +1,48 @@
|
|
|
1
|
-
//!
|
|
1
|
+
//! Per-invocation wasmtime export handles for the host-driven ABI
|
|
2
|
+
//! surface.
|
|
2
3
|
//!
|
|
3
|
-
//! `Runtime::
|
|
4
|
-
//!
|
|
5
|
-
//! `
|
|
6
|
-
//!
|
|
7
|
-
//!
|
|
8
|
-
//!
|
|
9
|
-
//!
|
|
4
|
+
//! `Runtime::instantiate` resolves the ABI exports the run path drives
|
|
5
|
+
//! (`__kobako_eval` / `__kobako_run` / `__kobako_take_outcome` /
|
|
6
|
+
//! `__kobako_alloc`) plus the `memory` export against each fresh
|
|
7
|
+
//! per-invocation instance (docs/behavior.md B-49) and bundles their
|
|
8
|
+
//! typed handles here, so the invocation body passes one struct around
|
|
9
|
+
//! rather than re-resolving exports by name at every step. Distinct
|
|
10
|
+
//! from `super::cache` (the process-wide Engine / Module cache): this
|
|
11
|
+
//! carries *which guest function to call*, per invocation.
|
|
10
12
|
//!
|
|
11
|
-
//! `
|
|
12
|
-
//!
|
|
13
|
+
//! `super::dispatch` does not reach this struct — a host import runs
|
|
14
|
+
//! against a `Caller`, so the dispatch path resolves `__kobako_alloc`
|
|
15
|
+
//! and `memory` through `Caller::get_export` instead.
|
|
13
16
|
|
|
14
|
-
use wasmtime::{AsContextMut, Instance as WtInstance, TypedFunc};
|
|
17
|
+
use wasmtime::{AsContextMut, Instance as WtInstance, Memory, TypedFunc};
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/// The cached host-driven export handles. Each is `Option` because test
|
|
19
|
+
/// The resolved host-driven export handles. Each is `Option` because test
|
|
19
20
|
/// fixtures (a minimal "ping" module) need not provide them; real
|
|
20
21
|
/// `kobako.wasm` always does, and the run-path methods raise a Ruby
|
|
21
|
-
/// `Kobako::TrapError` (via `require_export`) when a
|
|
22
|
+
/// `Kobako::TrapError` (via `require_export` / `require_memory`) when a
|
|
23
|
+
/// handle is `None`.
|
|
24
|
+
///
|
|
25
|
+
/// The handles are indices into the owning Store, not borrows of the
|
|
26
|
+
/// `Instance` — they stay valid for the Store's lifetime, which is why
|
|
27
|
+
/// no `Instance` field is kept.
|
|
22
28
|
pub(crate) struct Exports {
|
|
23
29
|
pub(crate) eval: Option<TypedFunc<(), ()>>,
|
|
24
30
|
pub(crate) run: Option<TypedFunc<(i32, i32), ()>>,
|
|
25
31
|
pub(crate) take_outcome: Option<TypedFunc<(), u64>>,
|
|
32
|
+
pub(crate) alloc: Option<TypedFunc<u32, u32>>,
|
|
33
|
+
pub(crate) memory: Option<Memory>,
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
impl Exports {
|
|
29
|
-
/// Best-effort lookup of the
|
|
30
|
-
///
|
|
37
|
+
/// Best-effort lookup of the host-driven exports against a freshly
|
|
38
|
+
/// instantiated module. Missing exports are not an error here
|
|
31
39
|
/// (the test fixture is a bare module); the host enforces presence at
|
|
32
40
|
/// invocation time. Only the SPEC ABI shapes are accepted —
|
|
33
41
|
/// `__kobako_eval` is `() -> ()`, `__kobako_run` is
|
|
34
|
-
/// `(env_ptr, env_len) -> ()`, `__kobako_take_outcome` is `() -> u64
|
|
42
|
+
/// `(env_ptr, env_len) -> ()`, `__kobako_take_outcome` is `() -> u64`,
|
|
43
|
+
/// `__kobako_alloc` is `(len) -> ptr`
|
|
35
44
|
/// (docs/wire-codec.md § ABI Signatures).
|
|
36
|
-
pub(crate) fn resolve(instance: &WtInstance,
|
|
37
|
-
let mut store_ref = store.borrow_mut();
|
|
38
|
-
let mut ctx = store_ref.as_context_mut();
|
|
45
|
+
pub(crate) fn resolve(instance: &WtInstance, mut ctx: impl AsContextMut) -> Self {
|
|
39
46
|
Self {
|
|
40
47
|
eval: instance
|
|
41
48
|
.get_typed_func::<(), ()>(&mut ctx, "__kobako_eval")
|
|
@@ -46,6 +53,10 @@ impl Exports {
|
|
|
46
53
|
take_outcome: instance
|
|
47
54
|
.get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
|
|
48
55
|
.ok(),
|
|
56
|
+
alloc: instance
|
|
57
|
+
.get_typed_func::<u32, u32>(&mut ctx, "__kobako_alloc")
|
|
58
|
+
.ok(),
|
|
59
|
+
memory: instance.get_memory(&mut ctx, "memory"),
|
|
49
60
|
}
|
|
50
61
|
}
|
|
51
62
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//! Per-path cache of pre-instantiated wasmtime artifacts.
|
|
2
|
+
//!
|
|
3
|
+
//! The `Linker` wiring (the WASI preview1 import set plus the
|
|
4
|
+
//! `__kobako_dispatch` host import) and its type-check against the
|
|
5
|
+
//! compiled Module are identical for every `Kobako::Runtime` on the
|
|
6
|
+
//! same Guest Binary — both host closures read all their state from
|
|
7
|
+
//! the `Invocation` inside the calling Store, never from the Runtime.
|
|
8
|
+
//! Caching the resolved `InstancePre` per path leaves only the
|
|
9
|
+
//! `instantiate` call itself on the `Runtime.from_path` hot path.
|
|
10
|
+
//!
|
|
11
|
+
//! Concurrency: see `super::cache` — under Ruby's GVL the Mutex serves
|
|
12
|
+
//! `Sync` bounds rather than real contention.
|
|
13
|
+
|
|
14
|
+
use std::collections::HashMap;
|
|
15
|
+
use std::path::{Path, PathBuf};
|
|
16
|
+
use std::sync::{Mutex, OnceLock};
|
|
17
|
+
|
|
18
|
+
use magnus::{Error as MagnusError, Ruby};
|
|
19
|
+
use wasmtime::{Caller, InstancePre, Linker};
|
|
20
|
+
use wasmtime_wasi::p1;
|
|
21
|
+
|
|
22
|
+
use super::cache::{cached_module, shared_engine};
|
|
23
|
+
use super::invocation::Invocation;
|
|
24
|
+
use super::{dispatch, setup_err, trap};
|
|
25
|
+
|
|
26
|
+
static INSTANCE_PRE_CACHE: OnceLock<Mutex<HashMap<PathBuf, InstancePre<Invocation>>>> =
|
|
27
|
+
OnceLock::new();
|
|
28
|
+
|
|
29
|
+
/// Look up `path` in the per-path `InstancePre` cache, wiring the
|
|
30
|
+
/// Linker and resolving the Module's imports on a miss. Compilation
|
|
31
|
+
/// faults surface through `cached_module`; import-resolution faults
|
|
32
|
+
/// raise `Kobako::SetupError` (docs/behavior.md E-41).
|
|
33
|
+
pub(crate) fn cached_instance_pre(path: &Path) -> Result<InstancePre<Invocation>, MagnusError> {
|
|
34
|
+
let cache = INSTANCE_PRE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
|
35
|
+
|
|
36
|
+
if let Some(pre) = cache
|
|
37
|
+
.lock()
|
|
38
|
+
.expect("instance_pre cache mutex poisoned")
|
|
39
|
+
.get(path)
|
|
40
|
+
.cloned()
|
|
41
|
+
{
|
|
42
|
+
return Ok(pre);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let module = cached_module(path)?;
|
|
46
|
+
let linker = build_linker()?;
|
|
47
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
48
|
+
let pre = linker
|
|
49
|
+
.instantiate_pre(&module)
|
|
50
|
+
.map_err(|e| trap::instantiate_err(&ruby, e))?;
|
|
51
|
+
cache
|
|
52
|
+
.lock()
|
|
53
|
+
.expect("instance_pre cache mutex poisoned")
|
|
54
|
+
.insert(path.to_path_buf(), pre.clone());
|
|
55
|
+
Ok(pre)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Build the host-import `Linker` every Guest Binary instantiates
|
|
59
|
+
/// against.
|
|
60
|
+
fn build_linker() -> Result<Linker<Invocation>, MagnusError> {
|
|
61
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
62
|
+
let mut linker: Linker<Invocation> = Linker::new(shared_engine()?);
|
|
63
|
+
|
|
64
|
+
// Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2
|
|
65
|
+
// to the MemoryOutputPipes set up before each run via
|
|
66
|
+
// `Runtime::eval`. The closure pulls a `&mut WasiP1Ctx` out of
|
|
67
|
+
// Invocation; the panic semantics live inside `Invocation::wasi_mut`
|
|
68
|
+
// so the wiring stays honest about its precondition.
|
|
69
|
+
p1::add_to_linker_sync(&mut linker, |state: &mut Invocation| state.wasi_mut())
|
|
70
|
+
.map_err(|e| setup_err(&ruby, format!("failed to set up the WASI runtime: {}", e)))?;
|
|
71
|
+
|
|
72
|
+
// `__kobako_dispatch` host import. Signature per docs/wire-codec.md
|
|
73
|
+
// § ABI Signatures:
|
|
74
|
+
// (req_ptr: i32, req_len: i32) -> i64
|
|
75
|
+
// Decodes the Request bytes, dispatches via the Ruby-side
|
|
76
|
+
// dispatch Proc (bound per-Sandbox through `Runtime#on_dispatch=`),
|
|
77
|
+
// allocates a guest buffer through `__kobako_alloc`, writes
|
|
78
|
+
// the Response bytes there, and returns the packed
|
|
79
|
+
// `(ptr<<32)|len`. The dispatcher returns 0 on any wire-layer
|
|
80
|
+
// fault (including no Proc bound); see `dispatch::handle`.
|
|
81
|
+
linker
|
|
82
|
+
.func_wrap(
|
|
83
|
+
"env",
|
|
84
|
+
"__kobako_dispatch",
|
|
85
|
+
|mut caller: Caller<'_, Invocation>, req_ptr: i32, req_len: i32| -> i64 {
|
|
86
|
+
dispatch::handle(&mut caller, req_ptr, req_len)
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
.map_err(|e| {
|
|
90
|
+
setup_err(
|
|
91
|
+
&ruby,
|
|
92
|
+
format!("failed to set up the host callback bridge: {}", e),
|
|
93
|
+
)
|
|
94
|
+
})?;
|
|
95
|
+
|
|
96
|
+
Ok(linker)
|
|
97
|
+
}
|