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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3177d01c5cb742a539ac9713226c17c159299911344b1590a680aacd99aa70fd
4
- data.tar.gz: 6f9a4503a56855076b1f5d83725775669ae4c34283b24bcb8467485509e26c03
3
+ metadata.gz: f94d5012ea43dae1d5bd51d2b8f0f6c7357edc259c7771577d3454c7055a5f1d
4
+ data.tar.gz: 494087c5769aa0ce019ac47d705a1426fb237840ab3c88700afdcc970816b43d
5
5
  SHA512:
6
- metadata.gz: b8f0f15f5c76c54b785e109ca6f7c10a37ebc7394d49f05637b18ace67a1326f941aa0a03ee99b985d8b09b70c39ba42c8318f9dd4fe885dba530472e5b24c81
7
- data.tar.gz: 794e0c2f476fceaee007fbeb1c8082accd8d1de2de682aa333606bc0ab6593ed281abda277222ca51d58a88f2663d37220e8068891396565221ecc694bdc2b68
6
+ metadata.gz: 9cd941922d385068bcb6e78849f5d43dc68e8bc36e2a6a04b245e406c488a2313cffc697441f69c2f76f3ea154924c67e594271009308702aa4f72c514bc0983
7
+ data.tar.gz: 5a3b07b25b6ea434f69731749c5756d802aa585d2f10cdb3097dafdaf442d1d1da61a1c338cbb0eb3506915d2ba968baf7906607ec97059f1da5ac1070cd6950
@@ -1 +1 @@
1
- {".":"0.9.2","wasm/kobako-core":"0.4.1","wasm/kobako":"0.4.1","wasm/kobako-io":"0.4.1","wasm/kobako-regexp":"0.4.1"}
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
@@ -878,9 +878,11 @@ dependencies = [
878
878
 
879
879
  [[package]]
880
880
  name = "kobako"
881
- version = "0.9.2"
881
+ version = "0.10.0"
882
882
  dependencies = [
883
+ "libc",
883
884
  "magnus",
885
+ "sha2",
884
886
  "wasmtime",
885
887
  "wasmtime-wasi",
886
888
  ]
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. allocate fresh mrb_state
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 mrb_state; reset per-invocation state:
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 against the fresh `mrb_state` before every invocation; `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).
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
- fresh mrb_state
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 mrb_state discarded
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` in a fresh process (Engine + Module JIT) | ~600 ms one-time |
316
- | Subsequent `Sandbox.new` (Engine cache warm) | ~125 µs |
317
- | Warm `#eval("nil")` on a reused Sandbox | ~135 µs |
318
- | Warm `#run(:Entrypoint, ...)` dispatch | ~165 µs |
319
- | Service call amortized inside one invocation | ~6.7 µs |
320
- | Snippet replay per invocation | ~7-9 µs each |
321
- | Per additional Sandbox (RSS) | ~570 KB |
322
-
323
- Construct one Sandbox at boot so the ~600 ms JIT cost lands off the request hot path. `ext/` does not release the GVL during wasmtime execution, so wasm work is GVL-serialized: aggregate throughput stays around 7-8k `#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.
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
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.9.2"
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 module = WtModule::new(shared_engine()?, &bytes)
128
- .map_err(|e| setup_err(&ruby, format!("failed to compile Sandbox runtime: {}", e)))?;
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 built in `super::Runtime::build`.
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 built in `super::Runtime::build`.
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
- //! Cached wasmtime export handles for the host-driven ABI surface.
1
+ //! Per-invocation wasmtime export handles for the host-driven ABI
2
+ //! surface.
2
3
  //!
3
- //! `Runtime::from_path` resolves the three docs/wire-codec.md ABI exports
4
- //! the run path drives (`__kobako_eval` / `__kobako_run` /
5
- //! `__kobako_take_outcome`) once at construction and stores their typed
6
- //! handles here, so each `#eval` / `#run` calls a cached handle rather than
7
- //! re-resolving the export by name. Distinct from `super::cache` (the
8
- //! process-wide Engine / Module cache): this caches *which guest function
9
- //! to call*, per `Runtime`.
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
- //! `__kobako_alloc` is deliberately absentonly `super::dispatch` calls
12
- //! it, and it does so through `Caller::get_export` on the wasmtime side.
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
- use super::invocation::StoreCell;
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 handle is `None`.
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 three host-driven exports against a
30
- /// freshly instantiated module. Missing exports are not an error here
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, store: &StoreCell) -> Self {
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
+ }