kobako 0.1.2 → 0.2.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +95 -60
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +1 -1
  6. data/ext/kobako/src/wasm/cache.rs +39 -1
  7. data/ext/kobako/src/wasm/dispatch.rs +20 -20
  8. data/ext/kobako/src/wasm/host_state.rs +261 -34
  9. data/ext/kobako/src/wasm/instance.rs +467 -272
  10. data/ext/kobako/src/wasm.rs +50 -19
  11. data/lib/kobako/capture.rb +46 -0
  12. data/lib/kobako/codec/decoder.rb +66 -0
  13. data/lib/kobako/codec/encoder.rb +37 -0
  14. data/lib/kobako/codec/error.rb +33 -0
  15. data/lib/kobako/codec/factory.rb +155 -0
  16. data/lib/kobako/codec/utils.rb +55 -0
  17. data/lib/kobako/codec.rb +27 -0
  18. data/lib/kobako/errors.rb +24 -1
  19. data/lib/kobako/outcome/panic.rb +42 -0
  20. data/lib/kobako/outcome.rb +133 -0
  21. data/lib/kobako/rpc/dispatcher.rb +169 -0
  22. data/lib/kobako/rpc/envelope.rb +118 -0
  23. data/lib/kobako/{wire/exception.rb → rpc/fault.rb} +6 -4
  24. data/lib/kobako/{wire → rpc}/handle.rb +4 -2
  25. data/lib/kobako/{registry → rpc}/handle_table.rb +9 -9
  26. data/lib/kobako/{registry/service_group.rb → rpc/namespace.rb} +20 -11
  27. data/lib/kobako/rpc/server.rb +156 -0
  28. data/lib/kobako/rpc.rb +11 -0
  29. data/lib/kobako/sandbox.rb +149 -69
  30. data/lib/kobako/version.rb +1 -1
  31. data/lib/kobako/wasm.rb +6 -16
  32. data/lib/kobako.rb +2 -0
  33. data/sig/kobako/capture.rbs +13 -0
  34. data/sig/kobako/codec/decoder.rbs +11 -0
  35. data/sig/kobako/codec/encoder.rbs +7 -0
  36. data/sig/kobako/codec/error.rbs +18 -0
  37. data/sig/kobako/codec/factory.rbs +31 -0
  38. data/sig/kobako/codec/utils.rbs +9 -0
  39. data/sig/kobako/errors.rbs +52 -0
  40. data/sig/kobako/outcome/panic.rbs +34 -0
  41. data/sig/kobako/outcome.rbs +24 -0
  42. data/sig/kobako/rpc/dispatcher.rbs +33 -0
  43. data/sig/kobako/rpc/envelope.rbs +51 -0
  44. data/sig/kobako/rpc/fault.rbs +20 -0
  45. data/sig/kobako/rpc/handle.rbs +19 -0
  46. data/sig/kobako/rpc/handle_table.rbs +25 -0
  47. data/sig/kobako/rpc/namespace.rbs +24 -0
  48. data/sig/kobako/rpc/server.rbs +37 -0
  49. data/sig/kobako/rpc.rbs +4 -0
  50. data/sig/kobako/sandbox.rbs +53 -0
  51. data/sig/kobako/wasm.rbs +37 -0
  52. data/sig/kobako.rbs +0 -1
  53. metadata +37 -17
  54. data/lib/kobako/registry/dispatcher.rb +0 -168
  55. data/lib/kobako/registry.rb +0 -160
  56. data/lib/kobako/sandbox/outcome_decoder.rb +0 -100
  57. data/lib/kobako/sandbox/output_buffer.rb +0 -79
  58. data/lib/kobako/wire/codec/decoder.rb +0 -87
  59. data/lib/kobako/wire/codec/encoder.rb +0 -41
  60. data/lib/kobako/wire/codec/error.rb +0 -35
  61. data/lib/kobako/wire/codec/factory.rb +0 -136
  62. data/lib/kobako/wire/codec.rb +0 -44
  63. data/lib/kobako/wire/envelope/payloads.rb +0 -145
  64. data/lib/kobako/wire/envelope.rb +0 -147
  65. data/lib/kobako/wire.rb +0 -40
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07f319ca70c97b9baa6d70eb177f2065d6a74544587e55527ce948f5921816af
4
- data.tar.gz: 9c598b981d38f624599cb7dcae745747a651265d9803a3a69915daf8634f0d5d
3
+ metadata.gz: 5762f0343892aa61a22ad0bc458f958abcf37726bb9d07285109d57ae20c69ea
4
+ data.tar.gz: 805bd0255ce3a16f777584a7efd21b56bf3058981d79a2ddadd815b62bb32a2a
5
5
  SHA512:
6
- metadata.gz: 3a834499680c55b497f473480c90192a012353894737bba9a26a3ed9a3b211af86fa00699e086c1ac5e2957bb04d088ed1852300899784494365ec7b237226ec
7
- data.tar.gz: 2eb27c6a401396a8dc2b4811281adf037b6629c10d43366ecd1b76ffebddfe5408eeaae76c10b36ec8bbc0754e38d2b5192260ac1f28dd422b9182712550d8a2
6
+ metadata.gz: 8e6df0644967ea64e585e2e5168d7db6a39be5d8100d1e69430bfb60d1e573a04aaa516033bdc80a48f5bb9127e5c37665824d5bc1333361964be3efb0f6ff62
7
+ data.tar.gz: 766fb001c38b111d24fcf8e07b1add66926fb3311016775f0f8eb4053113bb7cf9152339463023e5be88f5083e4db59686ad458569e83a7807eca5369c28276d
data/Cargo.lock CHANGED
@@ -864,7 +864,7 @@ dependencies = [
864
864
 
865
865
  [[package]]
866
866
  name = "kobako"
867
- version = "0.1.2"
867
+ version = "0.2.0"
868
868
  dependencies = [
869
869
  "magnus",
870
870
  "wasmtime",
data/README.md CHANGED
@@ -4,15 +4,31 @@ Kobako is a Ruby gem that embeds a Wasm-isolated mruby interpreter inside your a
4
4
 
5
5
  The host (`wasmtime`) runs a precompiled `kobako.wasm` guest containing mruby and an RPC client. The only way a guest script can reach the outside world is through Host App-declared **Services** — named Ruby objects you explicitly inject into the sandbox.
6
6
 
7
+ ```
8
+ Host process Wasm guest
9
+ ┌──────────────────────┐ ┌──────────────────────┐
10
+ │ Kobako::Sandbox │ ──run─▶ │ mruby interpreter │
11
+ │ │ │ │
12
+ │ Services │ ◀──RPC─ │ KV::Lookup.call(k) │
13
+ │ KV::Lookup │ ─resp─▶ │ │
14
+ │ │ │ │
15
+ │ stdout / stderr buf │ ◀─pipe─ │ puts / warn │
16
+ │ │ │ │
17
+ │ return value │ ◀─last─ │ last expression │
18
+ └──────────────────────┘ └──────────────────────┘
19
+ trusted untrusted
20
+ ```
21
+
7
22
  ## Features
8
23
 
9
24
  - **In-process Wasm sandbox** — no subprocess, no container. Each `Sandbox#run` is a synchronous Ruby call.
10
- - **Capability injection via Services** — guest scripts can only call Ruby objects you explicitly `bind` under a two-level `Group::Member` namespace.
11
- - **Structured outcome** — `#run` returns the deserialized last expression of the guest script as a normal Ruby value.
12
- - **Three-class error taxonomy** — every failure is exactly one of `TrapError` (Wasm engine), `SandboxError` (script / wire fault), or `ServiceError` (Service capability fault), so you can route errors without inspecting messages.
25
+ - **Per-run caps** — every `#run` enforces a wall-clock `timeout` (default 60 s) and a guest `memory_limit` (default 5 MiB). Exhaustion raises `Kobako::TimeoutError` / `Kobako::MemoryLimitError`.
26
+ - **Capability injection via Services** — guest scripts can only call Ruby objects you explicitly `bind` under a two-level `Namespace::Member` path.
27
+ - **Three-class error taxonomy** — every failure is exactly one of `TrapError` (Wasm engine / per-run cap), `SandboxError` (script / wire fault), or `ServiceError` (Service capability fault), so you can route errors without inspecting messages.
13
28
  - **Per-run state reset** — Handles issued during one `#run` are invalidated before the next; Service bindings remain.
14
- - **Separated stdout / stderr capture** — guest `puts`/`warn` output is buffered (1 MiB default cap, configurable, with a `[truncated]` marker on overflow) and is independent of the RPC channel.
15
- - **Capability Handles** — Services may return stateful host objects; the guest receives an opaque token it can use as the target of follow-up RPC calls, with no way to dereference it.
29
+ - **Separated stdout / stderr capture** — guest `puts` / `warn` / `print` / `printf` / `p` and writes to `$stdout` / `$stderr` are buffered per-channel (1 MiB default cap, configurable). Output past the cap is clipped; `#stdout_truncated?` / `#stderr_truncated?` report overflow.
30
+ - **Capability Handles** — Services may return stateful host objects; the guest receives an opaque `Kobako::RPC::Handle` proxy it can use as the target of follow-up RPC calls, with no way to dereference it.
31
+ - **Curated mruby stdlib** — core extensions plus `mruby-onig-regexp` for full Onigmo `Regexp` support. No mrbgem with I/O, network, or syscall access is bundled.
16
32
 
17
33
  ## Requirements
18
34
 
@@ -24,15 +40,9 @@ The precompiled `kobako.wasm` Guest Binary ships inside the gem, so end users do
24
40
 
25
41
  ## Installation
26
42
 
27
- Add Kobako to your Gemfile:
28
-
29
43
  ```bash
30
44
  bundle add kobako
31
- ```
32
-
33
- Or install it directly:
34
-
35
- ```bash
45
+ # or
36
46
  gem install kobako
37
47
  ```
38
48
 
@@ -55,7 +65,7 @@ The script executes inside the Wasm guest. It cannot read your filesystem, open
55
65
 
56
66
  ## Injecting Services
57
67
 
58
- Guest scripts reach host resources only through Services. Declare a **Group**, then `bind` named **Members** on it — each member can be any Ruby object that responds to the methods the guest will call.
68
+ Guest scripts reach host resources only through Services. Declare a **Namespace**, then `bind` named **Members** on it — each member can be any Ruby object that responds to the methods the guest will call.
59
69
 
60
70
  ```ruby
61
71
  sandbox = Kobako::Sandbox.new
@@ -70,7 +80,7 @@ RUBY
70
80
  # => "..." (the redis value)
71
81
  ```
72
82
 
73
- Names must match the Ruby constant pattern `/\A[A-Z]\w*\z/`. Services declared before the first `#run` remain active across subsequent runs.
83
+ Names must match the Ruby constant pattern `/\A[A-Z]\w*\z/`. Services declared before the first `#run` remain active across subsequent runs; `define` after the first `#run` raises `ArgumentError`.
74
84
 
75
85
  ### Keyword arguments
76
86
 
@@ -83,9 +93,31 @@ sandbox.run('Geo::Lookup.call(name: "alice", region: "us")')
83
93
  # => "us/alice"
84
94
  ```
85
95
 
96
+ ## Per-run caps
97
+
98
+ Each Sandbox enforces a wall-clock timeout and a guest linear-memory cap on every `#run`. Both default to safe values; pass `nil` to `timeout` or `memory_limit` to disable that cap. The output caps (`stdout_limit` / `stderr_limit`) cannot be disabled — pass a large Integer instead.
99
+
100
+ ```ruby
101
+ sandbox = Kobako::Sandbox.new(
102
+ timeout: 5.0, # seconds, default 60.0
103
+ memory_limit: 10 * 1024 * 1024, # bytes, default 5 MiB
104
+ stdout_limit: 64 * 1024, # bytes, default 1 MiB
105
+ stderr_limit: 64 * 1024
106
+ )
107
+ ```
108
+
109
+ | Cap | Raises (subclass of `TrapError`) | Default |
110
+ |----------------|------------------------------------|----------|
111
+ | `timeout` | `Kobako::TimeoutError` | 60.0 s |
112
+ | `memory_limit` | `Kobako::MemoryLimitError` | 5 MiB |
113
+ | `stdout_limit` | output silently clipped at cap | 1 MiB |
114
+ | `stderr_limit` | output silently clipped at cap | 1 MiB |
115
+
116
+ The timeout deadline is absolute wall-clock from `#run` entry and is checked at guest Wasm safepoints. Long-running host Service callbacks still consume wall-clock time but do not themselves trap — the next guest safepoint will trap immediately on return if the deadline has passed.
117
+
86
118
  ## Capturing stdout and stderr
87
119
 
88
- Guest output is captured into per-run buffers and exposed independently from the return value:
120
+ Guest output is captured into per-run buffers and exposed independently from the return value. The buffers cover the full Ruby IO surface — `puts`, `print`, `printf`, `p`, `<<`, and writes through `$stdout` / `$stderr` — all routed through the host-captured WASI pipe.
89
121
 
90
122
  ```ruby
91
123
  sandbox = Kobako::Sandbox.new
@@ -101,10 +133,13 @@ sandbox.stdout # => "hello\n"
101
133
  sandbox.stderr # => "be careful\n"
102
134
  ```
103
135
 
104
- Each `#run` clears the buffers at start. Output past the per-channel cap is truncated; the buffer ends with `[truncated]` and `#run` still returns normally.
136
+ Each `#run` clears the buffers at start. Output past the per-channel cap is clipped at the cap boundary `#run` still returns normally, the bytes carry no truncation sentinel, and `#stdout_truncated?` / `#stderr_truncated?` flip to `true`.
105
137
 
106
138
  ```ruby
107
- Kobako::Sandbox.new(stdout_limit: 64 * 1024, stderr_limit: 64 * 1024)
139
+ sandbox = Kobako::Sandbox.new(stdout_limit: 64 * 1024)
140
+ sandbox.run('puts "a" * 100_000')
141
+ sandbox.stdout.bytesize # => 65_536
142
+ sandbox.stdout_truncated? # => true
108
143
  ```
109
144
 
110
145
  ## Error handling
@@ -115,7 +150,10 @@ Every `#run` either returns a value or raises exactly one of three classes:
115
150
  begin
116
151
  sandbox.run(script)
117
152
  rescue Kobako::TrapError => e
118
- # Wasm engine crashed: OOM, stack overflow, corrupted guest runtime.
153
+ # Wasm engine fault OR per-run cap exhaustion:
154
+ # - Kobako::TimeoutError (wall-clock timeout)
155
+ # - Kobako::MemoryLimitError (memory_limit exceeded)
156
+ # - Kobako::TrapError (engine crash / wire-violation fallback)
119
157
  # The Sandbox is unrecoverable — discard and recreate it.
120
158
  rescue Kobako::ServiceError => e
121
159
  # A Service call failed and the script did not rescue it.
@@ -126,13 +164,15 @@ rescue Kobako::SandboxError => e
126
164
  end
127
165
  ```
128
166
 
129
- `SandboxError` and `ServiceError` carry structured fields (`origin`, `klass`, `backtrace_lines`, `details`) when the guest produced a panic envelope.
167
+ `SandboxError` and `ServiceError` carry structured fields (`origin`, `klass`, `backtrace_lines`, `details`) when the guest produced a panic envelope. Named subclasses:
130
168
 
131
- `Kobako::ServiceError::Disconnected` is a named subclass raised when an RPC target Handle has been invalidated. `Kobako::HandleTableExhausted` is a named `SandboxError` subclass raised when the per-run Handle counter reaches its cap (2³¹ 1).
169
+ - `Kobako::TimeoutError` / `Kobako::MemoryLimitError` per-run cap exhaustion (subclasses of `TrapError`).
170
+ - `Kobako::ServiceError::Disconnected` — RPC target Handle has been invalidated.
171
+ - `Kobako::HandleTableExhausted` — per-run Handle counter reached its cap (2³¹ − 1); subclass of `SandboxError`.
132
172
 
133
173
  ## Capability Handles
134
174
 
135
- When a Service returns a stateful host object (anything beyond `nil` / Boolean / Integer / Float / String / Symbol / Array / Hash), the wire layer transparently allocates an opaque Handle. The guest receives a `Kobako::Handle` proxy it can use as the target of further RPC calls — but cannot dereference, forge from an integer, or smuggle across runs.
175
+ When a Service returns a stateful host object (anything beyond `nil` / Boolean / Integer / Float / String / Symbol / Array / Hash), the wire layer transparently allocates an opaque Handle. The guest receives a `Kobako::RPC::Handle` proxy it can use as the target of further RPC calls — but cannot dereference, forge from an integer, or smuggle across runs.
136
176
 
137
177
  ```ruby
138
178
  class Greeter
@@ -143,7 +183,7 @@ end
143
183
  sandbox.define(:Factory).bind(:Make, ->(name) { Greeter.new(name) })
144
184
 
145
185
  sandbox.run(<<~RUBY)
146
- g = Factory::Make.call("Bob") # g is a Kobako::Handle proxy
186
+ g = Factory::Make.call("Bob") # g is a Kobako::RPC::Handle proxy
147
187
  g.greet # second RPC, routed to the Greeter
148
188
  RUBY
149
189
  # => "hi, Bob"
@@ -165,60 +205,55 @@ sandbox.run('Data::Fetch.call("b")') # => "..." (same bindings, fresh state)
165
205
 
166
206
  For workloads that must be isolated from each other (e.g., one Sandbox per tenant, per student submission), 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.
167
207
 
168
- ## Development
208
+ ## Performance
169
209
 
170
- After checking out the repo:
210
+ Headline numbers from the current baseline (macOS arm64, Ruby 3.4.7, YJIT off — full results in [`benchmark/results/`](benchmark/results) and [`benchmark/README.md`](benchmark/README.md)).
171
211
 
172
- ```bash
173
- bin/setup # install dependencies
174
- bundle exec rake # default: compile + test + rubocop
175
- ```
176
-
177
- Building from source requires a WASI-capable Rust toolchain in addition to the standard host toolchain. The first compile walks the full vendor / mruby / wasm chain:
212
+ | What | Cost |
213
+ |---|---|
214
+ | First `Sandbox.new` in a fresh process (Engine init + Module JIT) | ~2.0 s one-time |
215
+ | Subsequent `Sandbox.new` (cache warm) | ~130 µs |
216
+ | Reusing a Sandbox for one `#run("nil")` | ~135 µs |
217
+ | Fresh Sandbox per request the tenant-isolation pattern | ~275 µs (+140 µs vs reuse) |
218
+ | Per-RPC cost amortized across 1 000 calls in one `#run` | ~35 µs |
219
+ | 100 000-iteration integer XOR loop in mruby | ~200 ms |
220
+ | 1 000 Onigmo `Regexp =~` matches | ~14 µs per match |
221
+ | Process RSS after the first `Sandbox.new` | ~150 MB (one-time) |
222
+ | Memory per additional Sandbox | ~575 KB |
223
+ | 1 000 isolated tenants in one process | ~730 MB total |
224
+ | Aggregate throughput across N Threads | GVL-bound — wasm execution serialized, modest scaling from Ruby-side overlap |
178
225
 
179
- ```bash
180
- bundle exec rake compile # build the native extension
181
- bundle exec rake wasm:build # rebuild data/kobako.wasm (requires vendor:setup + mruby:build)
182
- bundle exec rake test # run the Ruby test suite
183
- ```
226
+ Practical implications:
184
227
 
185
- `bin/console` opens an IRB session with the gem preloaded for experimentation.
228
+ - **Pre-warm at boot.** The ~2 s first-Sandbox cost is paid once per process; every subsequent Sandbox amortizes to micro-, not seconds. Construct one Sandbox at boot before serving requests.
229
+ - **Tenant isolation is affordable.** Per-request Sandbox construction adds ~140 µs of overhead; per-tenant RSS budget is ~575 KB plus one-time ~130 MB for the engine. 1 000 isolated tenants in a single Sidekiq / Puma worker is well within typical RSS limits.
230
+ - **Batch RPCs inside one `#run`.** A single Service call costs ~135 µs because each `#run` carries ~130 µs of setup; 1 000 calls inside one `#run` reduce the per-call cost to ~35 µs.
186
231
 
187
- To install the local checkout as a gem:
232
+ A +10% regression on any of the five SPEC-mandated benchmarks blocks release. See [`benchmark/README.md`](benchmark/README.md) for the full per-suite breakdown and known measurement caveats.
188
233
 
189
234
  ```bash
190
- bundle exec rake install
235
+ bundle exec rake bench # five gated regression benchmarks (≤ 1 MiB payloads, ~5-8 min)
191
236
  ```
192
237
 
193
- ## Performance
194
-
195
- Headline numbers from the current baseline (macOS arm64, Ruby 3.4.7 — full results in [`benchmark/results/`](benchmark/results)):
196
-
197
- | What | Cost |
198
- |---|---|
199
- | First `Sandbox.new` in a fresh process (Engine init + Module compile) | ~410 ms one-time |
200
- | Subsequent `Sandbox.new` (cache warm) | ~90 µs |
201
- | Reusing a Sandbox for one `#run("nil")` | ~67 µs |
202
- | Fresh Sandbox per request — the tenant-isolation pattern | ~175 µs (+110 µs versus reuse) |
203
- | Per-RPC cost amortized across many calls in one `#run` | ~5.4 µs |
204
- | 100 000-iteration integer XOR loop in mruby | ~44 ms |
205
- | One-time process memory for wasmtime Engine + Module | ~110 MB |
206
- | Memory per additional Sandbox after the first | ~200 KB |
207
- | 1 000 isolated tenants in one process (1 Sandbox each) | ~340 MB total |
208
- | Aggregate throughput across N Threads | GVL-bound — wasm execution is serialized, modest scaling from Ruby-side overlap |
238
+ ## Development
209
239
 
210
- Practical implications:
240
+ After checking out the repo:
211
241
 
212
- - **Pre-warm at boot.** The 410 ms first-Sandbox cost is paid once per process; every subsequent Sandbox amortizes to micro-, not milliseconds. Construct one Sandbox at boot before serving requests.
213
- - **Tenant isolation is affordable.** Per-request Sandbox construction adds ~110 µs of overhead; per-tenant RSS budget is ~200 KB plus one-time ~110 MB for the engine. 1 000 isolated tenants in a single Sidekiq / Puma worker is well within typical RSS limits.
214
- - **Batch RPCs inside one `#run`.** A single Service call costs ~76 µs because each `#run` carries ~67 µs of setup; 1 000 calls inside one `#run` reduce the per-call cost to ~5.4 µs.
242
+ ```bash
243
+ bin/setup # install dependencies
244
+ bundle exec rake # default: compile + test + rubocop + steep
245
+ ```
215
246
 
216
- A +10% regression on any of the five SPEC-mandated benchmarks blocks release. See [`benchmark/README.md`](benchmark/README.md) for the full per-suite breakdown, rake task reference, and known measurement caveats (guest String size cap, GVL bounds, allocator retention).
247
+ Building from source requires a WASI-capable Rust toolchain in addition to the standard host toolchain. The first compile walks the full vendor / mruby / wasm chain:
217
248
 
218
249
  ```bash
219
- bundle exec rake bench # five gated regression benchmarks (≤ 1 MiB payloads, ~5-7 min)
250
+ bundle exec rake compile # build the native extension
251
+ bundle exec rake wasm:build # rebuild data/kobako.wasm
252
+ bundle exec rake test # run the Ruby test suite
220
253
  ```
221
254
 
255
+ `bin/console` opens an IRB session with the gem preloaded for experimentation. To install the local checkout as a gem, run `bundle exec rake install`.
256
+
222
257
  ## Contributing
223
258
 
224
259
  Bug reports and pull requests are welcome at <https://github.com/elct9620/kobako>. Please open an issue before starting on non-trivial changes so we can align on scope.
data/data/kobako.wasm CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  edition = "2021"
5
5
  authors = ["Aotokitsuruya <contact@aotoki.me>"]
6
6
  license = "Apache-2.0"
@@ -22,6 +22,8 @@ use std::collections::HashMap;
22
22
  use std::fs;
23
23
  use std::path::{Path, PathBuf};
24
24
  use std::sync::{Mutex, OnceLock};
25
+ use std::thread;
26
+ use std::time::Duration;
25
27
 
26
28
  use magnus::{Error as MagnusError, Ruby};
27
29
  use wasmtime::{Config as WtConfig, Engine as WtEngine, Module as WtModule};
@@ -31,6 +33,15 @@ use super::{wasm_err, MODULE_NOT_BUILT_ERROR};
31
33
  static SHARED_ENGINE: OnceLock<WtEngine> = OnceLock::new();
32
34
  static MODULE_CACHE: OnceLock<Mutex<HashMap<PathBuf, WtModule>>> = OnceLock::new();
33
35
 
36
+ /// Ticker cadence for the process-singleton epoch ticker. Bounds the
37
+ /// granularity of the SPEC.md B-01 wall-clock timeout: the
38
+ /// `epoch_deadline_callback` fires once per tick (`Continue(1)`), so the
39
+ /// trap can lag the deadline by at most one tick under nominal
40
+ /// scheduling. 10 ms keeps the lag small enough that it does not skew
41
+ /// short test timeouts while leaving the ticker cheap (one wake-up per
42
+ /// 10 ms across the whole process).
43
+ const EPOCH_TICK: Duration = Duration::from_millis(10);
44
+
34
45
  /// Return the process-wide wasmtime Engine, building it on first call.
35
46
  ///
36
47
  /// Enables the wasm exceptions proposal so `kobako.wasm` (which uses
@@ -40,17 +51,44 @@ static MODULE_CACHE: OnceLock<Mutex<HashMap<PathBuf, WtModule>>> = OnceLock::new
40
51
  /// exception handling instructions in the wasm32 object files;
41
52
  /// wasmtime must have the proposal enabled to parse and JIT those
42
53
  /// instructions.
54
+ ///
55
+ /// Also enables `epoch_interruption(true)` so every Store can install an
56
+ /// `epoch_deadline_callback` for the per-run wall-clock cap (SPEC.md
57
+ /// B-01, E-19). The first call spawns the process-singleton ticker
58
+ /// thread that drives `engine.increment_epoch()` at [`EPOCH_TICK`]
59
+ /// cadence; subsequent calls reuse the same engine and ticker.
43
60
  pub(crate) fn shared_engine() -> Result<&'static WtEngine, MagnusError> {
44
61
  if let Some(engine) = SHARED_ENGINE.get() {
45
62
  return Ok(engine);
46
63
  }
47
64
  let mut config = WtConfig::new();
48
65
  config.wasm_exceptions(true);
66
+ config.epoch_interruption(true);
49
67
  let engine = WtEngine::new(&config).map_err(|e| {
50
68
  let ruby = Ruby::get().expect("Ruby thread");
51
69
  wasm_err(&ruby, format!("engine init: {}", e))
52
70
  })?;
53
- Ok(SHARED_ENGINE.get_or_init(|| engine))
71
+ let engine = SHARED_ENGINE.get_or_init(|| engine);
72
+ spawn_epoch_ticker(engine.clone());
73
+ Ok(engine)
74
+ }
75
+
76
+ /// Spawn the process-singleton epoch ticker. The thread holds a clone of
77
+ /// the shared Engine (`wasmtime::Engine` is reference-counted internally)
78
+ /// and ticks the epoch counter at [`EPOCH_TICK`] cadence. Idempotent
79
+ /// across reentrant calls to [`shared_engine`] because [`OnceLock`]
80
+ /// gates the spawn.
81
+ fn spawn_epoch_ticker(engine: WtEngine) {
82
+ static TICKER_SPAWNED: OnceLock<()> = OnceLock::new();
83
+ TICKER_SPAWNED.get_or_init(|| {
84
+ thread::Builder::new()
85
+ .name("kobako-epoch-ticker".into())
86
+ .spawn(move || loop {
87
+ thread::sleep(EPOCH_TICK);
88
+ engine.increment_epoch();
89
+ })
90
+ .expect("spawn kobako-epoch-ticker thread");
91
+ });
54
92
  }
55
93
 
56
94
  /// Look up `path` in the per-path Module cache, compiling and inserting
@@ -1,12 +1,12 @@
1
- //! Host-side dispatch for the `__kobako_rpc_call` import.
1
+ //! Host-side dispatch for the `__kobako_dispatch` import.
2
2
  //!
3
3
  //! When the guest invokes the wasm import declared in
4
4
  //! `wasm/kobako-wasm/src/abi.rs`, wasmtime calls back into the host
5
- //! through the closure built in [`super::instance::build_instance`].
5
+ //! through the closure built in [`super::instance::Instance::build`].
6
6
  //! That closure delegates here. The dispatcher (SPEC.md B-12 / B-13):
7
7
  //!
8
8
  //! 1. Reads the Request bytes from guest linear memory.
9
- //! 2. Hands them to the Ruby-side `Kobako::Registry` and recovers
9
+ //! 2. Hands them to the Ruby-side `Kobako::RPC::Server` and recovers
10
10
  //! Response bytes.
11
11
  //! 3. Allocates a guest buffer via `__kobako_alloc(len)` invoked
12
12
  //! through `Caller::get_export`.
@@ -14,10 +14,10 @@
14
14
  //! 5. Returns packed `(ptr<<32)|len` for the guest to decode.
15
15
  //!
16
16
  //! Returns 0 on any step failure. `Kobako::Sandbox` always installs a
17
- //! Registry before invoking the guest, so reaching the dispatcher with
18
- //! no Registry bound is itself a wire-layer fault; the guest maps a 0
17
+ //! Server before invoking the guest, so reaching the dispatcher with
18
+ //! no Server bound is itself a wire-layer fault; the guest maps a 0
19
19
  //! return to a trap. Failures during normal dispatch surface as
20
- //! Response.err envelopes from the Registry itself — they never reach
20
+ //! Response.err envelopes from the Server itself — they never reach
21
21
  //! this 0-return path.
22
22
 
23
23
  use magnus::value::{Opaque, ReprValue};
@@ -26,24 +26,24 @@ use wasmtime::{Caller, Extern};
26
26
 
27
27
  use super::host_state::HostState;
28
28
 
29
- /// Drive a single `__kobako_rpc_call` invocation end-to-end. Entry point
30
- /// from the wasmtime closure built in [`super::instance::build_instance`].
31
- pub(crate) fn dispatch_rpc(caller: &mut Caller<'_, HostState>, req_ptr: i32, req_len: i32) -> i64 {
29
+ /// Drive a single `__kobako_dispatch` invocation end-to-end. Entry point
30
+ /// from the wasmtime closure built in [`super::instance::Instance::build`].
31
+ pub(crate) fn handle(caller: &mut Caller<'_, HostState>, req_ptr: i32, req_len: i32) -> i64 {
32
32
  let req_bytes = match read_caller_memory(caller, req_ptr, req_len) {
33
33
  Some(b) => b,
34
34
  None => return 0,
35
35
  };
36
36
 
37
- // No Registry bound — return 0 to signal a wire-layer fault; the guest
37
+ // No Server bound — return 0 to signal a wire-layer fault; the guest
38
38
  // maps a 0 return to a trap. `Kobako::Sandbox` always installs a
39
- // Registry before invoking the guest, so reaching this branch indicates
39
+ // Server before invoking the guest, so reaching this branch indicates
40
40
  // a misuse rather than a normal control path.
41
- let registry = match caller.data().registry {
41
+ let server = match caller.data().server() {
42
42
  Some(d) => d,
43
43
  None => return 0,
44
44
  };
45
45
 
46
- let resp_bytes = match invoke_registry(registry, &req_bytes) {
46
+ let resp_bytes = match invoke_server(server, &req_bytes) {
47
47
  Ok(b) => b,
48
48
  Err(_) => return 0,
49
49
  };
@@ -51,20 +51,20 @@ pub(crate) fn dispatch_rpc(caller: &mut Caller<'_, HostState>, req_ptr: i32, req
51
51
  write_response(caller, &resp_bytes).unwrap_or(0)
52
52
  }
53
53
 
54
- /// Call the Ruby Registry's `#dispatch(request_bytes)` method and return
55
- /// the encoded Response bytes. Errors here mean the Registry itself
54
+ /// Call the Ruby Server's `#dispatch(request_bytes)` method and return
55
+ /// the encoded Response bytes. Errors here mean the Server itself
56
56
  /// failed (it is contracted never to raise — see
57
- /// `Kobako::Registry#dispatch`), which we treat as a wire-layer fault.
58
- fn invoke_registry(registry: Opaque<Value>, req_bytes: &[u8]) -> Result<Vec<u8>, MagnusError> {
57
+ /// `Kobako::RPC::Server#dispatch`), which we treat as a wire-layer fault.
58
+ fn invoke_server(server: Opaque<Value>, req_bytes: &[u8]) -> Result<Vec<u8>, MagnusError> {
59
59
  // The wasmtime callback runs on the same Ruby thread that called
60
60
  // Sandbox#run — the invariant SPEC Implementation Standards
61
61
  // Architecture pins for the host gem — so `Ruby::get()` is always
62
62
  // available here. Panicking with `expect` localises the violation
63
63
  // rather than letting a nonsense error propagate.
64
- let ruby = Ruby::get().expect("Ruby handle unavailable in __kobako_rpc_call");
65
- let registry_value: Value = ruby.get_inner(registry);
64
+ let ruby = Ruby::get().expect("Ruby handle unavailable in __kobako_dispatch");
65
+ let server_value: Value = ruby.get_inner(server);
66
66
  let req_str = ruby.str_from_slice(req_bytes);
67
- let resp: RString = registry_value.funcall("dispatch", (req_str,))?;
67
+ let resp: RString = server_value.funcall("dispatch", (req_str,))?;
68
68
  // SAFETY: the returned RString is held by the Ruby VM for the duration of
69
69
  // this scope; copying its bytes into a Vec is a defensive standard pattern.
70
70
  let bytes = unsafe { resp.as_slice() }.to_vec();