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.
- checksums.yaml +4 -4
- data/Cargo.lock +1 -1
- data/README.md +95 -60
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +1 -1
- data/ext/kobako/src/wasm/cache.rs +39 -1
- data/ext/kobako/src/wasm/dispatch.rs +20 -20
- data/ext/kobako/src/wasm/host_state.rs +261 -34
- data/ext/kobako/src/wasm/instance.rs +467 -272
- data/ext/kobako/src/wasm.rs +50 -19
- data/lib/kobako/capture.rb +46 -0
- data/lib/kobako/codec/decoder.rb +66 -0
- data/lib/kobako/codec/encoder.rb +37 -0
- data/lib/kobako/codec/error.rb +33 -0
- data/lib/kobako/codec/factory.rb +155 -0
- data/lib/kobako/codec/utils.rb +55 -0
- data/lib/kobako/codec.rb +27 -0
- data/lib/kobako/errors.rb +24 -1
- data/lib/kobako/outcome/panic.rb +42 -0
- data/lib/kobako/outcome.rb +133 -0
- data/lib/kobako/rpc/dispatcher.rb +169 -0
- data/lib/kobako/rpc/envelope.rb +118 -0
- data/lib/kobako/{wire/exception.rb → rpc/fault.rb} +6 -4
- data/lib/kobako/{wire → rpc}/handle.rb +4 -2
- data/lib/kobako/{registry → rpc}/handle_table.rb +9 -9
- data/lib/kobako/{registry/service_group.rb → rpc/namespace.rb} +20 -11
- data/lib/kobako/rpc/server.rb +156 -0
- data/lib/kobako/rpc.rb +11 -0
- data/lib/kobako/sandbox.rb +149 -69
- data/lib/kobako/version.rb +1 -1
- data/lib/kobako/wasm.rb +6 -16
- data/lib/kobako.rb +2 -0
- data/sig/kobako/capture.rbs +13 -0
- data/sig/kobako/codec/decoder.rbs +11 -0
- data/sig/kobako/codec/encoder.rbs +7 -0
- data/sig/kobako/codec/error.rbs +18 -0
- data/sig/kobako/codec/factory.rbs +31 -0
- data/sig/kobako/codec/utils.rbs +9 -0
- data/sig/kobako/errors.rbs +52 -0
- data/sig/kobako/outcome/panic.rbs +34 -0
- data/sig/kobako/outcome.rbs +24 -0
- data/sig/kobako/rpc/dispatcher.rbs +33 -0
- data/sig/kobako/rpc/envelope.rbs +51 -0
- data/sig/kobako/rpc/fault.rbs +20 -0
- data/sig/kobako/rpc/handle.rbs +19 -0
- data/sig/kobako/rpc/handle_table.rbs +25 -0
- data/sig/kobako/rpc/namespace.rbs +24 -0
- data/sig/kobako/rpc/server.rbs +37 -0
- data/sig/kobako/rpc.rbs +4 -0
- data/sig/kobako/sandbox.rbs +53 -0
- data/sig/kobako/wasm.rbs +37 -0
- data/sig/kobako.rbs +0 -1
- metadata +37 -17
- data/lib/kobako/registry/dispatcher.rb +0 -168
- data/lib/kobako/registry.rb +0 -160
- data/lib/kobako/sandbox/outcome_decoder.rb +0 -100
- data/lib/kobako/sandbox/output_buffer.rb +0 -79
- data/lib/kobako/wire/codec/decoder.rb +0 -87
- data/lib/kobako/wire/codec/encoder.rb +0 -41
- data/lib/kobako/wire/codec/error.rb +0 -35
- data/lib/kobako/wire/codec/factory.rb +0 -136
- data/lib/kobako/wire/codec.rb +0 -44
- data/lib/kobako/wire/envelope/payloads.rb +0 -145
- data/lib/kobako/wire/envelope.rb +0 -147
- data/lib/kobako/wire.rb +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5762f0343892aa61a22ad0bc458f958abcf37726bb9d07285109d57ae20c69ea
|
|
4
|
+
data.tar.gz: 805bd0255ce3a16f777584a7efd21b56bf3058981d79a2ddadd815b62bb32a2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8e6df0644967ea64e585e2e5168d7db6a39be5d8100d1e69430bfb60d1e573a04aaa516033bdc80a48f5bb9127e5c37665824d5bc1333361964be3efb0f6ff62
|
|
7
|
+
data.tar.gz: 766fb001c38b111d24fcf8e07b1add66926fb3311016775f0f8eb4053113bb7cf9152339463023e5be88f5083e4db59686ad458569e83a7807eca5369c28276d
|
data/Cargo.lock
CHANGED
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
|
-
- **
|
|
11
|
-
- **
|
|
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
|
|
15
|
-
- **Capability Handles** — Services may return stateful host objects; the guest receives an opaque
|
|
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 **
|
|
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
|
|
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
|
|
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
|
|
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::
|
|
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
|
-
##
|
|
208
|
+
## Performance
|
|
169
209
|
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
235
|
+
bundle exec rake bench # five gated regression benchmarks (≤ 1 MiB payloads, ~5-8 min)
|
|
191
236
|
```
|
|
192
237
|
|
|
193
|
-
##
|
|
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
|
-
|
|
240
|
+
After checking out the repo:
|
|
211
241
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
242
|
+
```bash
|
|
243
|
+
bin/setup # install dependencies
|
|
244
|
+
bundle exec rake # default: compile + test + rubocop + steep
|
|
245
|
+
```
|
|
215
246
|
|
|
216
|
-
|
|
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
|
|
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
|
data/ext/kobako/Cargo.toml
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
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::
|
|
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::
|
|
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
|
-
//!
|
|
18
|
-
//! no
|
|
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
|
|
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 `
|
|
30
|
-
/// from the wasmtime closure built in [`super::instance::
|
|
31
|
-
pub(crate) fn
|
|
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
|
|
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
|
-
//
|
|
39
|
+
// Server before invoking the guest, so reaching this branch indicates
|
|
40
40
|
// a misuse rather than a normal control path.
|
|
41
|
-
let
|
|
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
|
|
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
|
|
55
|
-
/// the encoded Response bytes. Errors here mean the
|
|
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::
|
|
58
|
-
fn
|
|
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
|
|
65
|
-
let
|
|
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 =
|
|
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();
|