kobako 0.9.2-aarch64-linux → 0.11.0-aarch64-linux

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: 79a9c1dea3f6872fd9b3639279a348e85bb1e9435142acd793618251d37039af
4
- data.tar.gz: 246fbed779b9af927c755a171a7df5e0e07ff95c4defe50be6f116de22573cb3
3
+ metadata.gz: 8a44a6f91fb5e57a8cc4cfca97e501fab889eef17961c8088b736625e0a06983
4
+ data.tar.gz: 5ce894137c6df04ddf48b4bbd821bf76764a06663d7552624539e6a6ffa88242
5
5
  SHA512:
6
- metadata.gz: 2f3d140f333c2fc8a9a5a67bd94608082173e9796ebb7302ef2d750dc76e5fbd88df2b36aff92f06275162411a5d3debedbd3816970cc9ff9c18cf7ac7ecc4d2
7
- data.tar.gz: 44e5b86f5a2aa3b232ba18cee0c7a4623b849f010bca33a057cb5a59b04b7c9bb658350629f5c139c9b819ca43603a6217ae1552a300ea78aa832c0406b327df
6
+ metadata.gz: 8d28583de8802ed0dcab2cb1138d671674cf9ca11823a766ec9a46a075c73aa24d40da121273da0c506fddebea1a6fa1cd75e1041aff4a2929391b7b97d5e064
7
+ data.tar.gz: c6aea42ae6d840cee738ee3306e2963d4ad4e60f6e640835e9f38ef98a323a267f04986d27afb08ee6bb21665a245bee97e05eaba13596220dbbd914f2325b56
@@ -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.11.0","wasm/kobako-core":"0.5.0","wasm/kobako":"0.5.0","wasm/kobako-io":"0.5.0","wasm/kobako-regexp":"0.5.0","wasm/kobako-baker":"0.5.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0](https://github.com/elct9620/kobako/compare/v0.10.0...v0.11.0) (2026-06-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * **transport:** narrow guest-reachable methods via respond_to_guest? ([7a25fe3](https://github.com/elct9620/kobako/commit/7a25fe3b440b523b8a692b7601d08877b1305b0d))
9
+
10
+ ## [0.10.0](https://github.com/elct9620/kobako/compare/v0.9.2...v0.10.0) (2026-06-12)
11
+
12
+
13
+ ### Features
14
+
15
+ * **catalog:** reject member binding after the seal (E-45) ([5193ed6](https://github.com/elct9620/kobako/commit/5193ed64100fa8ac05a2ba18cfa00634b4f40e6f))
16
+ * **guest:** bake the canonical boot state and instantiate per invocation (B-49) ([ee9ae6e](https://github.com/elct9620/kobako/commit/ee9ae6e09eab30f54dba0eeec00a5a2c80da819f))
17
+ * **pool:** add Kobako::Pool warm-Sandbox checkout (B-46..B-48) ([abf9bf8](https://github.com/elct9620/kobako/commit/abf9bf8d3c725c0ca0b8f2ab8b2ddd6f71ee6de4))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **ext:** give the ABI probe a WASI context ([18e21ea](https://github.com/elct9620/kobako/commit/18e21eac8b160ade2724578aeacd86170403ee2c))
23
+ * **ext:** trust the artifact disk cache only in an exclusively writable directory ([17679cc](https://github.com/elct9620/kobako/commit/17679cc0d38d2a1b605e2faaeed762477a718c18))
24
+
25
+
26
+ ### Performance Improvements
27
+
28
+ * **bench:** re-bless the anchor onto the post-0.9.2 performance round ([195224d](https://github.com/elct9620/kobako/commit/195224d5d48e53dc2be17752e6d6af6382a0c1ec))
29
+ * **catalog:** drop the alloc-path block iteration from the gadget refusal ([542fe59](https://github.com/elct9620/kobako/commit/542fe59464bde57283d8e91984b82e82592bc3ab))
30
+ * **ext:** amortise module compilation across processes via .cwasm cache ([2e688bc](https://github.com/elct9620/kobako/commit/2e688bc4a1cdf1d0d4d5a0bce2efb314a5b8d1f7))
31
+ * **ext:** bound and harden the compiled-artifact cache ([949f222](https://github.com/elct9620/kobako/commit/949f2227af7cdf7d1913dcae58df683912a7dbd5))
32
+ * **ext:** cache ABI export handles and per-path InstancePre ([47573d0](https://github.com/elct9620/kobako/commit/47573d022233c788ce94413d1a2901ee9d62fc2e))
33
+ * **lib:** cache sealed frame encodings and cut decode-walk allocations ([e599573](https://github.com/elct9620/kobako/commit/e599573e37531b363baca83a1aa5833930100320))
34
+
3
35
  ## [0.9.2](https://github.com/elct9620/kobako/compare/v0.9.1...v0.9.2) (2026-06-11)
4
36
 
5
37
 
data/README.md CHANGED
@@ -69,7 +69,7 @@ The script executes inside the Wasm guest. It cannot read your filesystem, open
69
69
 
70
70
  ### Services
71
71
 
72
- Declare a Namespace, then `bind` any Ruby object as a Member; the guest reaches it as a `<Namespace>::<Member>` proxy and invokes its public methods through the Transport wire. See [`docs/behavior.md`](docs/behavior.md) B-07..B-12.
72
+ Declare a Namespace, then `bind` any Ruby object as a Member; the guest reaches it as a `<Namespace>::<Member>` proxy and invokes its public methods through the Transport wire. See [`docs/behavior/registration.md`](docs/behavior/registration.md) B-07..B-12.
73
73
 
74
74
  ```ruby
75
75
  class User
@@ -93,7 +93,7 @@ Names must match `/\A[A-Z]\w*\z/`. Symbol kwargs travel transparently to the hos
93
93
 
94
94
  ### Output Capture
95
95
 
96
- Guest writes through `puts` / `print` / `p` / `$stdout` / `$stderr` are buffered per-channel and exposed independently of the return value ([`docs/behavior.md`](docs/behavior.md) B-04). Buffers clear at the start of each invocation; overflow is clipped at the cap and flagged by `#stdout_truncated?` / `#stderr_truncated?`.
96
+ Guest writes through `puts` / `print` / `p` / `$stdout` / `$stderr` are buffered per-channel and exposed independently of the return value ([`docs/behavior/lifecycle.md`](docs/behavior/lifecycle.md) B-04). Buffers clear at the start of each invocation; overflow is clipped at the cap and flagged by `#stdout_truncated?` / `#stderr_truncated?`.
97
97
 
98
98
  ```ruby
99
99
  result = sandbox.eval(<<~RUBY)
@@ -134,7 +134,7 @@ end
134
134
 
135
135
  ### Resource Limits
136
136
 
137
- Each invocation enforces a wall-clock `timeout` and a per-invocation linear-memory `memory_limit`; exhaustion raises a `TrapError` subclass. Pass `nil` to `timeout` / `memory_limit` to disable that cap. Read [`Sandbox#usage`](lib/kobako/sandbox.rb) after the call — populated on every outcome including traps — for actual consumption ([`docs/behavior.md`](docs/behavior.md) B-35).
137
+ Each invocation enforces a wall-clock `timeout` and a per-invocation linear-memory `memory_limit`; exhaustion raises a `TrapError` subclass. Pass `nil` to `timeout` / `memory_limit` to disable that cap. Read [`Sandbox#usage`](lib/kobako/sandbox.rb) after the call — populated on every outcome including traps — for actual consumption ([`docs/behavior/lifecycle.md`](docs/behavior/lifecycle.md) B-35).
138
138
 
139
139
  ```ruby
140
140
  sandbox = Kobako::Sandbox.new(
@@ -179,7 +179,8 @@ One Sandbox serves many invocations. Service bindings and preloaded snippets per
179
179
 
180
180
  ──────────────── invocation N ───────────────────
181
181
 
182
- 1. 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,9 +200,30 @@ One Sandbox serves many invocations. Service bindings and preloaded snippets per
199
200
 
200
201
  For workloads that must be isolated from each other (one Sandbox per tenant, per student submission, per agent session), construct a fresh `Kobako::Sandbox` per scope — wasmtime's Engine and the compiled Module are cached at process scope, so additional Sandboxes amortize cold-start cost automatically.
201
202
 
203
+ ### Pooling
204
+
205
+ For hosts that serve many short invocations, `Kobako::Pool` keeps a bounded set of warm, identically set-up Sandboxes and hands each one to a single exclusive holder at a time ([`docs/behavior/runtime.md`](docs/behavior/runtime.md) B-46..B-48). Construction forwards every `Sandbox.new` keyword verbatim; the optional block is the per-Sandbox setup window and runs exactly once per constructed Sandbox.
206
+
207
+ `Kobako::Pool` is experimental today and is best treated as a convenience for warm, pre-configured reuse rather than a throughput optimisation. B-49 bakes the shared boot state into the artifact and every dynamic script still compiles and runs per invocation, so all a pool actually saves is the ~30 µs host-side `Sandbox.new`. For the workload kobako is built for — many small, short-lived Sandboxes running dynamic scripts — that is not a significant gain (~4-5% in the [serverless example](examples/serverless/README.md), and proportionally less once the script itself does real work).
208
+
209
+ ```ruby
210
+ pool = Kobako::Pool.new(slots: 4) do |sandbox|
211
+ sandbox.define(:KV).bind(:Lookup, ->(key) { redis.get(key) })
212
+ end
213
+
214
+ pool.with { |sandbox| sandbox.eval(%(KV::Lookup.call("user_42"))) }
215
+ ```
216
+
217
+ | Option | Meaning | Default |
218
+ |--------|---------|---------|
219
+ | `slots:` | Upper bound on constructed Sandboxes | required |
220
+ | `checkout_timeout:` | Seconds `#with` waits for a free Sandbox; `nil` waits indefinitely | 5.0 |
221
+
222
+ Sandboxes construct lazily on first demand. `#with` yields a Sandbox with empty output buffers and returns the block's value; at block exit the Sandbox returns to the pool, except a block that raises `Kobako::TrapError` discards its Sandbox and the slot refills by a fresh construction on next demand. A checkout that waits past `checkout_timeout` raises `Kobako::PoolTimeoutError`. There is no teardown verb — a Pool releases everything with its own reachability.
223
+
202
224
  ### Service Blocks
203
225
 
204
- A Service method can accept a guest-supplied block via `&blk` and `yield` into it. The block body runs inside the Wasm guest; `break` / `next` / exceptions follow normal Ruby semantics, scoped to the single dispatch. See [`docs/behavior.md`](docs/behavior.md) B-23..B-30.
226
+ A Service method can accept a guest-supplied block via `&blk` and `yield` into it. The block body runs inside the Wasm guest; `break` / `next` / exceptions follow normal Ruby semantics, scoped to the single dispatch. See [`docs/behavior/yield.md`](docs/behavior/yield.md) B-23..B-30.
205
227
 
206
228
  ```ruby
207
229
  sandbox.define(:Seq).bind(:Map, ->(items, &blk) { items.map(&blk) })
@@ -212,7 +234,7 @@ sandbox.eval('Seq::Map.call([1, 2, 3]) { |x| x * 2 }')
212
234
 
213
235
  ### Handle Management
214
236
 
215
- A non-wire-representable host object — returned from a Service (B-14), passed to `#run` (B-34), or handed back from the guest (B-37) — crosses the boundary as an opaque `Kobako::Handle` proxy and is restored to the original object before host code sees it; any other unrepresentable value raises `Kobako::SandboxError`. Handles are scoped to a single invocation ([`docs/behavior.md`](docs/behavior.md) B-13..B-21, B-34, B-37).
237
+ A non-wire-representable host object — returned from a Service (B-14), passed to `#run` (B-34), or handed back from the guest (B-37) — crosses the boundary as an opaque `Kobako::Handle` proxy and is restored to the original object before host code sees it; any other unrepresentable value raises `Kobako::SandboxError`. Handles are scoped to a single invocation ([`docs/behavior/dispatch.md`](docs/behavior/dispatch.md) B-13..B-21, B-34, B-37).
216
238
 
217
239
  ```ruby
218
240
  class Greeter
@@ -248,7 +270,7 @@ This is deliberate, not a leak. Handle IDs run to 2³¹ − 1 per invocation and
248
270
 
249
271
  ### Snippets & Entrypoints
250
272
 
251
- `Sandbox#preload` registers named mruby snippets that replay 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).
273
+ `Sandbox#preload` registers named mruby snippets that replay into every invocation's canonical boot state; `Sandbox#run(:Target, *args, **kwargs)` dispatches into a top-level `Object` constant defined by those snippets ([`docs/behavior/invocation.md`](docs/behavior/invocation.md) B-31..B-33).
252
274
 
253
275
  ```ruby
254
276
  sandbox = Kobako::Sandbox.new
@@ -262,7 +284,7 @@ sandbox.run(:Greeter, name: "world") # => "hello, world"
262
284
  ```
263
285
  per-invocation replay (every #eval / #run, snippets in insertion order):
264
286
 
265
- fresh mrb_state
287
+ canonical boot state
266
288
 
267
289
  ├──▶ replay :Adder (defines Adder)
268
290
 
@@ -271,7 +293,7 @@ sandbox.run(:Greeter, name: "world") # => "hello, world"
271
293
  └──▶ eval(source) -or- run(:Target, *args, **kwargs)
272
294
 
273
295
 
274
- return value, then mrb_state discarded
296
+ return value, then instance discarded
275
297
  ```
276
298
 
277
299
  `#preload` accepts two payload forms:
@@ -300,6 +322,11 @@ sandbox.define(:Cfg).bind(:Settings, ThemeReader.new) # not: bind(:Settings, Ap
300
322
  sandbox.eval('Cfg::Settings.color') # => "#3366ff" — every other method raises NoMethodError
301
323
  ```
302
324
 
325
+ When a purpose-built wrapper is more than you need, an object can gate its own surface in
326
+ place: a private `respond_to_guest?(name)` answers, per method, whether the guest may call
327
+ it. Returning `false` for every name makes the object opaque — a credential the guest
328
+ forwards to another Service but never reads — while a named subset becomes an allow-list.
329
+
303
330
  Guest code can name any `<Namespace>::<Member>` path, but a forged name only resolves to
304
331
  something you bound — the real authorization gate is this host-side allowlist. Give each
305
332
  trust context its own Sandbox, and see [`docs/security.md`](docs/security.md) for the rest
@@ -312,15 +339,16 @@ Order-of-magnitude figures on macOS arm64, Ruby 3.4.7, YJIT off. Absolute values
312
339
 
313
340
  | Phase | Cost |
314
341
  |--------------------------------------------------------------|-----------------------|
315
- | First `Sandbox.new` 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.
342
+ | First `Sandbox.new` ever for a Guest Binary (Module JIT, then disk-cached) | ~500 ms once per machine |
343
+ | First `Sandbox.new` in a fresh process (`.cwasm` cache warm) | ~5 ms one-time |
344
+ | Subsequent `Sandbox.new` (caches warm) | ~30 µs |
345
+ | Warm `#eval("nil")` on a reused Sandbox | ~73 µs |
346
+ | Warm `#run(:Entrypoint, ...)` dispatch | ~104 µs |
347
+ | Service call amortized inside one invocation | ~6.8 µs |
348
+ | Snippet replay per invocation | ~8 µs each |
349
+ | Per additional idle Sandbox (RSS) | ~1 KB |
350
+
351
+ The Cranelift JIT runs once per machine and gem version — the compiled artifact persists in a `.cwasm` disk cache, so later processes deserialize in milliseconds. An idle Sandbox holds no wasm instance (the canonical boot state is baked into the artifact and instantiated per invocation), which is why a thousand idle tenants cost ~32 MB total. `ext/` does not release the GVL during wasmtime execution, so wasm work is GVL-serialized: aggregate throughput stays around 16k `#eval`/s regardless of Thread count, though Ruby-side `#eval` setup still overlaps. A +10% regression on any of the six SPEC-mandated benchmarks blocks release.
324
352
 
325
353
  Regexp is an opt-in capability gem, excluded from the default binary and the gated set; its throughput is tracked in a separate non-gated characterization (`#10` in [`benchmark/README.md`](benchmark/README.md)). There `=~` (~5 µs/match) costs about 4× `match?` (~1.2 µs), because `=~` eagerly builds the `MatchData` and match globals — prefer `match?` for boolean tests.
326
354
 
data/data/kobako.wasm CHANGED
Binary file
Binary file
Binary file
Binary file
@@ -4,7 +4,7 @@ module Kobako
4
4
  # Host-side captured prefix of guest stdout / stderr produced during a
5
5
  # single +Kobako::Sandbox+ invocation, paired with the truncation flag
6
6
  # the WASI pipe sets when the guest wrote past the configured per-channel
7
- # cap ({docs/behavior.md B-04}[link:../../docs/behavior.md]).
7
+ # cap.
8
8
  #
9
9
  # Immutable value object: the captured bytes and the truncation flag
10
10
  # always travel together and the instance is frozen on construction.
@@ -30,14 +30,12 @@ module Kobako
30
30
  end
31
31
 
32
32
  # Returns +true+ iff the underlying capture channel exceeded its
33
- # configured cap during the originating +Sandbox+ invocation
34
- # ({docs/behavior.md B-04}[link:../../docs/behavior.md]).
33
+ # configured cap during the originating +Sandbox+ invocation.
35
34
  def truncated? = @truncated
36
35
 
37
- # Pre-invocation sentinel ({docs/behavior.md B-05}[link:../../docs/behavior.md]).
38
- # Empty UTF-8 bytes and +truncated? == false+; reused by every fresh
39
- # +Sandbox+ and by +Sandbox+ between invocations to denote "no capture
40
- # yet".
36
+ # Pre-invocation sentinel. Empty UTF-8 bytes and +truncated? == false+;
37
+ # reused by every fresh +Sandbox+ and by +Sandbox+ between invocations
38
+ # to denote "no capture yet".
41
39
  EMPTY = new(bytes: "", truncated: false)
42
40
  end
43
41
  end
@@ -5,41 +5,29 @@ require_relative "../handle"
5
5
  module Kobako
6
6
  module Catalog
7
7
  # Host-side mapping from opaque integer Handle IDs to Ruby objects.
8
- # The table is owned by +Kobako::Sandbox+
9
- # ({docs/behavior.md B-19}[link:../../../docs/behavior.md]) and injected
8
+ # The table is owned by +Kobako::Sandbox+ and injected
10
9
  # into the per-Sandbox +Kobako::Catalog::Namespaces+ so guest→host dispatch
11
10
  # resolves Handle targets and arguments against the same table that
12
- # host→guest wire encoding allocates into
13
- # ({docs/behavior.md B-14, B-34}[link:../../../docs/behavior.md]).
11
+ # host→guest wire encoding allocates into.
14
12
  #
15
- # Lifecycle invariants ({docs/behavior.md}[link:../../../docs/behavior.md]):
13
+ # Lifecycle invariants:
16
14
  #
17
- # - {docs/behavior.md B-15}[link:../../../docs/behavior.md] Handle IDs
18
- # are allocated by a monotonically increasing counter scoped to a
19
- # single invocation. The first ID issued in an invocation is 1; ID 0
20
- # is reserved as the invalid sentinel and is never returned by
21
- # +#alloc+.
15
+ # - Handle IDs are allocated by a monotonically increasing counter
16
+ # scoped to a single invocation. The first ID issued in an
17
+ # invocation is 1; ID 0 is reserved as the invalid sentinel and is
18
+ # never returned by +#alloc+.
22
19
  #
23
- # - {docs/behavior.md B-19}[link:../../../docs/behavior.md] At every
24
- # invocation boundary (via +#reset!+), every Handle issued under the
25
- # old state becomes invalid. Reset applies uniformly regardless of
26
- # allocation source (B-14 Service return or B-34 host-injected
20
+ # - At every invocation boundary (via +#reset!+), every Handle issued
21
+ # under the old state becomes invalid. Reset applies uniformly
22
+ # regardless of allocation source (Service return or host-injected
27
23
  # argument).
28
24
  #
29
- # - {docs/behavior.md B-21}[link:../../../docs/behavior.md] The cap is
30
- # +0x7fff_ffff+ (2³¹ 1). Allocation beyond the cap raises
31
- # immediately — no silent truncation, no wrap, no ID reuse.
25
+ # - The cap is +0x7fff_ffff+ (2³¹ − 1). Allocation beyond the cap
26
+ # raises immediately no silent truncation, no wrap, no ID reuse.
32
27
  class Handles
33
- # Reflective gadget types that are never minted as a Capability Handle
34
- # ({docs/behavior.md B-43}[link:../../../docs/behavior.md]): wrapping a
35
- # +Binding+ / +Method+ / +UnboundMethod+ would hand the guest a callable
36
- # proxy onto host reflection (a returned +Binding+ reaches +Binding#eval+).
37
- UNWRAPPABLE_TYPES = [Binding, Method, UnboundMethod].freeze
38
- private_constant :UNWRAPPABLE_TYPES
39
-
40
28
  # Build a fresh, empty table. +next_id+ is an internal seam that
41
- # sets the starting value of the monotonic counter (defaults to 1 per
42
- # B-15); tests pass a value near +Kobako::Handle::MAX_ID+ to exercise
29
+ # sets the starting value of the monotonic counter (defaults to 1);
30
+ # tests pass a value near +Kobako::Handle::MAX_ID+ to exercise
43
31
  # the cap-exhaustion path without 2³¹ allocations.
44
32
  def initialize(next_id: 1)
45
33
  @entries = {} # : Hash[Integer, untyped]
@@ -52,8 +40,7 @@ module Kobako
52
40
  # +[Kobako::Handle::MIN_ID, Kobako::Handle::MAX_ID]+. Raises
53
41
  # +Kobako::HandlerExhaustedError+ if the next ID would exceed the
54
42
  # cap. The cap is anchored on +Kobako::Handle+ — the wire codec
55
- # and the allocator share the same invariant
56
- # ({docs/behavior.md B-21}[link:../../../docs/behavior.md]).
43
+ # and the allocator share the same invariant.
57
44
  #
58
45
  # Returning a Handle (rather than a bare Integer id) keeps the
59
46
  # allocator's output a domain entity; +Kobako::Handle.restore+
@@ -76,9 +63,8 @@ module Kobako
76
63
  @entries[id]
77
64
  end
78
65
 
79
- # Clear all entries AND reset the counter to 1. Called at the per-invocation
80
- # boundary by +Kobako::Sandbox+ see
81
- # {docs/behavior.md B-19}[link:../../../docs/behavior.md]. Returns +self+.
66
+ # Clear all entries AND reset the counter to 1. Called at the
67
+ # per-invocation boundary by +Kobako::Sandbox+. Returns +self+.
82
68
  def reset!
83
69
  @entries.clear
84
70
  @next_id = 1
@@ -96,17 +82,20 @@ module Kobako
96
82
 
97
83
  private
98
84
 
99
- # Refuse to mint a Capability Handle for a reflective gadget
100
- # ({UNWRAPPABLE_TYPES}, B-43). Raising here keeps the rule at the single
101
- # mint point, so it holds on both the Service-return (B-14) and the
102
- # +#run+ host→guest auto-wrap (B-34) paths.
85
+ # Refuse to mint a Capability Handle for a reflective gadget:
86
+ # a +Binding+ / +Method+ / +UnboundMethod+ would hand the guest a
87
+ # callable proxy onto host reflection (a returned +Binding+ reaches
88
+ # +Binding#eval+). Raising here keeps the rule at the single mint
89
+ # point, so it holds on both the Service-return and the +#run+
90
+ # host→guest auto-wrap paths.
103
91
  def reject_unwrappable!(object)
104
- return unless UNWRAPPABLE_TYPES.any? { |type| object.is_a?(type) }
105
-
106
- raise SandboxError, "a #{object.class} cannot cross as a Capability Handle"
92
+ case object
93
+ when Binding, Method, UnboundMethod
94
+ raise SandboxError, "a #{object.class} cannot cross as a Capability Handle"
95
+ end
107
96
  end
108
97
 
109
- # Guard {#alloc} against issuing an ID past the B-21 cap. Returns +nil+
98
+ # Guard {#alloc} against issuing an ID past the cap. Returns +nil+
110
99
  # on success; raises +Kobako::HandlerExhaustedError+ at exhaustion.
111
100
  def ensure_capacity!
112
101
  cap = Kobako::Handle::MAX_ID
@@ -9,8 +9,7 @@ module Kobako
9
9
  module Catalog
10
10
  # Kobako::Catalog::Namespaces — per-Sandbox registry of
11
11
  # +Kobako::Namespace+ entities. Holds the Namespace / Member bindings
12
- # and the preamble emitted on Frame 1
13
- # ({docs/behavior.md B-07..B-11}[link:../../../docs/behavior.md]).
12
+ # and the preamble emitted on Frame 1.
14
13
  #
15
14
  # Public API:
16
15
  #
@@ -24,29 +23,27 @@ module Kobako
24
23
  # +Kobako::Transport::Dispatcher+'s responsibility — the Dispatcher
25
24
  # receives this registry and the +Catalog::Handles+ as arguments from
26
25
  # the +Runtime#on_dispatch+ Proc that +Kobako::Sandbox#initialize+
27
- # installs ({docs/behavior.md B-12}[link:../../../docs/behavior.md]).
28
- # The registry holds an injected +Catalog::Handles+ reference so
26
+ # installs. The registry holds an injected +Catalog::Handles+ reference so
29
27
  # dispatch target resolution and host→guest auto-wrap share the same
30
- # Sandbox-owned allocator ({docs/behavior.md B-19}[link:../../../docs/behavior.md]).
28
+ # Sandbox-owned allocator.
31
29
  class Namespaces
32
30
  # Build a fresh registry. +handler+ is an internal seam that injects
33
31
  # a pre-configured +Catalog::Handles+; tests pass one whose +next_id+
34
- # is pinned near +MAX_ID+ to exercise the B-21 cap-exhaustion path
32
+ # is pinned near +MAX_ID+ to exercise the cap-exhaustion path
35
33
  # without 2³¹ allocations. Production callers leave it at the default.
36
34
  def initialize(handler: Catalog::Handles.new)
37
35
  @namespaces = {} # : Hash[String, Kobako::Namespace]
38
36
  @handler = handler
39
37
  @sealed = false
38
+ @encoded = nil # : String?
40
39
  end
41
40
 
42
- # Declare or retrieve the Namespace named +name+ (idempotent
43
- # {docs/behavior.md B-10}[link:../../../docs/behavior.md]).
41
+ # Declare or retrieve the Namespace named +name+ (idempotent).
44
42
  # +name+ is a constant-form name as a +Symbol+ or +String+ (must satisfy
45
43
  # +Namespace::NAME_PATTERN+). Returns the +Kobako::Namespace+ for that
46
44
  # name, creating it if it does not exist. Raises +ArgumentError+ when
47
45
  # +name+ is malformed, or when called after the owning Sandbox has been
48
- # sealed by its first invocation
49
- # ({docs/behavior.md B-07}[link:../../../docs/behavior.md]).
46
+ # sealed by its first invocation.
50
47
  def define(name)
51
48
  raise ArgumentError, "cannot define after first Sandbox invocation" if @sealed
52
49
 
@@ -73,21 +70,35 @@ module Kobako
73
70
  namespace.fetch(member_name)
74
71
  end
75
72
 
76
- # Encode the preamble as msgpack bytes for stdin Frame 1 delivery
77
- # ({docs/behavior.md B-02}[link:../../../docs/behavior.md]). Routes through
78
- # {Kobako::Codec::Encoder} like every other host-side wire encode so
79
- # there is a single codec path; the preamble carries only Strings and
80
- # Arrays, so none of the kobako ext types actually fire. Structure:
81
- # +[["Namespace", ["MemberA", "MemberB"]], ...]+. Returns a binary
82
- # +String+ of msgpack bytes.
73
+ # Encode the preamble as msgpack bytes for stdin Frame 1 delivery.
74
+ # Routes through {Kobako::Codec::Encoder} like every other host-side
75
+ # wire encode so there is a single codec path; the preamble carries
76
+ # only Strings and Arrays, so none of the kobako ext types actually
77
+ # fire. Structure: +[["Namespace", ["MemberA", "MemberB"]], ...]+.
78
+ # Returns a binary +String+ of msgpack bytes.
79
+ #
80
+ # Once sealed, the bytes are computed once and reused for every
81
+ # subsequent invocation: sealing freezes Service registration at the
82
+ # first invocation, so the preamble is exactly the bindings that
83
+ # existed at that moment — a bind reaching a +Kobako::Namespace+
84
+ # after the seal raises +ArgumentError+ and never alters Frame 1.
83
85
  def encode
84
- Codec::Encoder.encode(@namespaces.values.map(&:to_preamble))
86
+ return @encoded if @encoded
87
+
88
+ bytes = Codec::Encoder.encode(@namespaces.values.map(&:to_preamble)).freeze
89
+ @encoded = bytes if @sealed
90
+ bytes
85
91
  end
86
92
 
87
- # Mark the registry as sealed. Called by +Sandbox+ on the first
88
- # invocation. After sealing, #define raises ArgumentError. Idempotent.
93
+ # Mark the registry as sealed and propagate the seal to every
94
+ # declared +Kobako::Namespace+. Called by +Sandbox+ on the first
95
+ # invocation. After sealing, both #define and +Namespace#bind+
96
+ # raise ArgumentError. Idempotent.
89
97
  def seal!
98
+ return self if @sealed
99
+
90
100
  @sealed = true
101
+ @namespaces.each_value(&:seal!)
91
102
  self
92
103
  end
93
104
 
@@ -6,8 +6,7 @@ require_relative "../snippet"
6
6
  module Kobako
7
7
  module Catalog
8
8
  # Kobako::Catalog::Snippets — per-Sandbox insertion-ordered registry
9
- # of preloaded snippets
10
- # ({docs/behavior.md B-32 / B-33}[link:../../../docs/behavior.md]).
9
+ # of preloaded snippets.
11
10
  #
12
11
  # Entries replay against the fresh +mrb_state+ before per-invocation
13
12
  # source / entrypoint resolution. Each +Snippet::Source+ entry's +name+
@@ -15,22 +14,21 @@ module Kobako
15
14
  # +debug_info+ that surfaces in every backtrace frame originating from
16
15
  # the snippet as +(snippet:Name):line+. Duplicate names within the
17
16
  # +code:+ form would produce ambiguous attribution and are rejected at
18
- # registration time
19
- # ({docs/behavior.md E-33}[link:../../../docs/behavior.md]).
17
+ # registration time.
20
18
  # +Snippet::Binary+ entries carry no host-side name — their canonical
21
19
  # name lives in the bytecode's +debug_info+ and is read by the guest at
22
20
  # load time; the host does not extract it.
23
21
  #
24
- # Sealing (B-33) is governed by the owning Sandbox — the registry itself
22
+ # Sealing is governed by the owning Sandbox — the registry itself
25
23
  # is append-only and exposes no mutation API beyond +#register+; the
26
24
  # Sandbox guards +#register+ behind the seal check before delegating.
27
25
  class Snippets
28
- # Ruby constant-name pattern enforced on snippet names
29
- # ({docs/behavior.md E-34}[link:../../../docs/behavior.md]).
26
+ # Ruby constant-name pattern enforced on snippet names.
30
27
  NAME_PATTERN = /\A[A-Z]\w*\z/
31
28
 
32
29
  def initialize
33
30
  @entries = [] # : Array[Kobako::Snippet::Source | Kobako::Snippet::Binary]
31
+ @encoded = nil # : String?
34
32
  end
35
33
 
36
34
  # Serialize the registered snippets to wire bytes. Each entry
@@ -42,12 +40,17 @@ module Kobako
42
40
  # carriers — this collection-tier method reads their attributes
43
41
  # externally via +entry_payload+ rather than asking each entry to
44
42
  # self-encode.
43
+ #
44
+ # The bytes are memoized — the table is replayed verbatim on every
45
+ # invocation after sealing, so Frame 3 never changes between
46
+ # encodes; {#register} drops the memo while the table is still open.
45
47
  def encode
46
- Codec::Encoder.encode(@entries.map { |entry| entry_payload(entry) })
48
+ return @encoded if @encoded
49
+
50
+ @encoded = Codec::Encoder.encode(@entries.map { |entry| entry_payload(entry) }).freeze
47
51
  end
48
52
 
49
- # Register one preloaded snippet in either of two forms
50
- # ({docs/behavior.md B-32}[link:../../../docs/behavior.md]).
53
+ # Register one preloaded snippet in either of two forms.
51
54
  #
52
55
  # * Source form +register(code: src, name: Name)+ — +src+ is the
53
56
  # mruby source as a String; the bytes are re-encoded as UTF-8
@@ -58,16 +61,15 @@ module Kobako
58
61
  # precompiled RITE bytecode as a String, duplicated and forced
59
62
  # to ASCII-8BIT so msgpack-ruby ships it as +bin+. Returns
60
63
  # +nil+ — bytecode entries are anonymous on the host side; any
61
- # structural validation
62
- # ({docs/behavior.md E-37 / E-38}[link:../../../docs/behavior.md])
63
- # is deferred to the guest at first replay.
64
+ # structural validation is deferred to the guest at first replay.
64
65
  #
65
66
  # The two forms are mutually exclusive: shape validation lives
66
67
  # here so callers (chiefly +Kobako::Sandbox#preload+) collapse to
67
68
  # a single delegation. Raises +ArgumentError+ on mixed forms,
68
- # missing keywords, wrong types, malformed +name+ (E-34), or
69
- # duplicate +code:+ +name+ (E-33).
69
+ # missing keywords, wrong types, malformed +name+, or
70
+ # duplicate +code:+ +name+.
70
71
  def register(code: nil, name: nil, binary: nil)
72
+ @encoded = nil
71
73
  if binary
72
74
  raise ArgumentError, "cannot combine binary: with code: / name:" if code || name
73
75
 
@@ -81,7 +83,7 @@ module Kobako
81
83
 
82
84
  # Source-form register path. Delegates argument-shape checks to
83
85
  # +ensure_source_args!+ (which returns the narrowed +[code, name]+
84
- # pair), normalises +name+ to a Symbol, rejects duplicates (E-33),
86
+ # pair), normalises +name+ to a Symbol, rejects duplicates,
85
87
  # and appends the Source entry.
86
88
  def register_source!(code, name)
87
89
  code, name = ensure_source_args!(code, name)
@@ -64,7 +64,11 @@ module Kobako
64
64
  case value
65
65
  when String then Utils.assert_utf8!(value, "str payload") if value.encoding == Encoding::UTF_8
66
66
  when Array then value.each { |v| validate_utf8!(v) }
67
- when Hash then value.each { |pair| validate_utf8!(pair) }
67
+ when Hash
68
+ value.each do |key, val|
69
+ validate_utf8!(key)
70
+ validate_utf8!(val)
71
+ end
68
72
  end
69
73
  end
70
74
  end
@@ -19,8 +19,7 @@ module Kobako
19
19
  # - Representability predicate ({representable?}) and the symmetric
20
20
  # host→guest +#run+ argument walk ({deep_wrap}) used by
21
21
  # +Kobako::Transport::Run#encode+ to route non-representable leaves
22
- # through the Sandbox's +Kobako::Catalog::Handles+
23
- # ({docs/behavior.md B-34}[link:../../../docs/behavior.md]).
22
+ # through the Sandbox's +Kobako::Catalog::Handles+.
24
23
  #
25
24
  # All helpers are pure — they only inspect inputs, never mutate
26
25
  # them — except {deep_wrap}, whose only side effect is allocating
@@ -84,8 +83,7 @@ module Kobako
84
83
 
85
84
  # Deep-walk Array / Hash containers in +value+ and replace every
86
85
  # leaf that fails {representable?} with a +Kobako::Handle+
87
- # allocated from +handler+
88
- # ({docs/behavior.md B-34}[link:../../../docs/behavior.md]). The
86
+ # allocated from +handler+. The
89
87
  # walk only descends through representable container shapes
90
88
  # (Array, Hash) one structural level at a time; a non-representable
91
89
  # leaf is wrapped as-is without inspecting its internal structure.
@@ -116,8 +114,7 @@ module Kobako
116
114
 
117
115
  # Deep-walk Array / Hash containers in +value+ and replace every
118
116
  # +Kobako::Handle+ leaf with the host-side object +handler+ resolves
119
- # it to ({docs/behavior.md B-37}[link:../../../docs/behavior.md]).
120
- # The symmetric inverse of {deep_wrap}: that walk allocates objects
117
+ # it to. The symmetric inverse of {deep_wrap}: that walk allocates objects
121
118
  # into Handles on the host→guest argument path; this walk resolves
122
119
  # Handles back to their objects on every guest→host value path — the
123
120
  # +#eval+ / +#run+ result and the yield-block result alike. The walk
@@ -126,11 +123,11 @@ module Kobako
126
123
  # unchanged.
127
124
  #
128
125
  # +value+ is a decoded Ruby value (a Handle here is a wire-decoded
129
- # +Kobako::Handle+, never a guest-forged one — B-20); +handler+ must
126
+ # +Kobako::Handle+, never a guest-forged one); +handler+ must
130
127
  # respond to +#fetch(id) -> object+ (a host-side
131
128
  # +Kobako::Catalog::Handles+). +handler.fetch+ raises
132
- # +Kobako::SandboxError+ for an id with no live binding, which is the
133
- # corrupted-runtime fallback B-37 specifies.
129
+ # +Kobako::SandboxError+ for an id with no live binding, the
130
+ # corrupted-runtime fallback.
134
131
  def deep_restore(value, handler)
135
132
  case value
136
133
  when ::Array then value.map { |element| Utils.deep_restore(element, handler) }