kobako 0.9.1-x86_64-linux → 0.10.0-x86_64-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: fab93ea70f79595c4c5cd7f155bb1337d62e71c4579f75b70654141078543b86
4
- data.tar.gz: 70a69865ed1501dcf539261bfa6c09158c28214a6784b87aa3ac1a6d46ef2b4b
3
+ metadata.gz: 9a2f9fdb62d68dfbb45d5cc88705b056e869a1f8ea527444a74daae4e2c24a7a
4
+ data.tar.gz: f6fd31dfb518022be1ca9619f5343fdf5a671caeffe451514eb0cde039371c6e
5
5
  SHA512:
6
- metadata.gz: d7e682bbe2cde22e04c25fffa94b5c9f2e024f3d6c0f9b368a45ee05c4ecd3bcc8c3c7ced054609a9644f7e34cf42ea33592acbd008f8202dba4853e9a0a98b0
7
- data.tar.gz: 9cb3083092c7f596512c9cd3f8c9c55bfd4f3a137546e41b738ad02dce6a67e8e1452fa746e463a9be60443cac1c8ab4801e12bb04d4ee7b41ebc3a4425d151e
6
+ metadata.gz: 7c61b7745938ff9f51dbf821d2f317ffaf11631651f6bfad695e07824c03fd7013e2d39bac95d31a951fa2b50138575907ce83a35364f7948a6464ed37bac710
7
+ data.tar.gz: c05f84febfd37914fd4fa80d48aea10b7041bdf756d50da749f85f9697e2365a1853e80579399a0d564244514107a7bd62907c8261c978d2143a2b0b55a67141
@@ -1 +1 @@
1
- {".":"0.9.1","wasm/kobako-core":"0.4.0","wasm/kobako":"0.4.0","wasm/kobako-io":"0.4.0","wasm/kobako-regexp":"0.4.0"}
1
+ {".":"0.10.0","wasm/kobako-core":"0.5.0","wasm/kobako":"0.5.0","wasm/kobako-io":"0.5.0","wasm/kobako-regexp":"0.5.0","wasm/kobako-baker":"0.5.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0](https://github.com/elct9620/kobako/compare/v0.9.2...v0.10.0) (2026-06-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * **catalog:** reject member binding after the seal (E-45) ([5193ed6](https://github.com/elct9620/kobako/commit/5193ed64100fa8ac05a2ba18cfa00634b4f40e6f))
9
+ * **guest:** bake the canonical boot state and instantiate per invocation (B-49) ([ee9ae6e](https://github.com/elct9620/kobako/commit/ee9ae6e09eab30f54dba0eeec00a5a2c80da819f))
10
+ * **pool:** add Kobako::Pool warm-Sandbox checkout (B-46..B-48) ([abf9bf8](https://github.com/elct9620/kobako/commit/abf9bf8d3c725c0ca0b8f2ab8b2ddd6f71ee6de4))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **ext:** give the ABI probe a WASI context ([18e21ea](https://github.com/elct9620/kobako/commit/18e21eac8b160ade2724578aeacd86170403ee2c))
16
+ * **ext:** trust the artifact disk cache only in an exclusively writable directory ([17679cc](https://github.com/elct9620/kobako/commit/17679cc0d38d2a1b605e2faaeed762477a718c18))
17
+
18
+
19
+ ### Performance Improvements
20
+
21
+ * **bench:** re-bless the anchor onto the post-0.9.2 performance round ([195224d](https://github.com/elct9620/kobako/commit/195224d5d48e53dc2be17752e6d6af6382a0c1ec))
22
+ * **catalog:** drop the alloc-path block iteration from the gadget refusal ([542fe59](https://github.com/elct9620/kobako/commit/542fe59464bde57283d8e91984b82e82592bc3ab))
23
+ * **ext:** amortise module compilation across processes via .cwasm cache ([2e688bc](https://github.com/elct9620/kobako/commit/2e688bc4a1cdf1d0d4d5a0bce2efb314a5b8d1f7))
24
+ * **ext:** bound and harden the compiled-artifact cache ([949f222](https://github.com/elct9620/kobako/commit/949f2227af7cdf7d1913dcae58df683912a7dbd5))
25
+ * **ext:** cache ABI export handles and per-path InstancePre ([47573d0](https://github.com/elct9620/kobako/commit/47573d022233c788ce94413d1a2901ee9d62fc2e))
26
+ * **lib:** cache sealed frame encodings and cut decode-walk allocations ([e599573](https://github.com/elct9620/kobako/commit/e599573e37531b363baca83a1aa5833930100320))
27
+
28
+ ## [0.9.2](https://github.com/elct9620/kobako/compare/v0.9.1...v0.9.2) (2026-06-11)
29
+
30
+
31
+ ### Bug Fixes
32
+
33
+ * **catalog:** never mint a Capability Handle for a reflective gadget ([6c2d29d](https://github.com/elct9620/kobako/commit/6c2d29d0fbcced5187df5538c2c6c437705fd6d8))
34
+ * **ext:** cap stdin frames at 16 MiB like the run envelope ([a94099a](https://github.com/elct9620/kobako/commit/a94099a03830929c55fcb266e227073db9c5a624))
35
+ * **ext:** deny guest ambient clock and entropy at the WASI layer ([1275b35](https://github.com/elct9620/kobako/commit/1275b35264813628a2aeb396a3546faf1f6d9d0c))
36
+ * **transport:** reject reflective gadget methods in guest dispatch ([948fb9e](https://github.com/elct9620/kobako/commit/948fb9ea7d6c0d6bd91f6e261d3263743974388b))
37
+ * **wasm:** mirror the reflection rejection in the guest proxy ([f6ead3b](https://github.com/elct9620/kobako/commit/f6ead3b91f1ac92c3c075397d177edb4b82cd15d))
38
+
3
39
  ## [0.9.1](https://github.com/elct9620/kobako/compare/v0.9.0...v0.9.1) (2026-06-11)
4
40
 
5
41
 
data/README.md CHANGED
@@ -179,7 +179,8 @@ One Sandbox serves many invocations. Service bindings and preloaded snippets per
179
179
 
180
180
  ──────────────── invocation N ───────────────────
181
181
 
182
- 1. allocate fresh mrb_state
182
+ 1. start from the canonical boot state
183
+ (mruby pre-initialized into the artifact at build time)
183
184
 
184
185
  2. replay snippets (in insertion order):
185
186
  :Adder → defines Adder
@@ -189,7 +190,7 @@ One Sandbox serves many invocations. Service bindings and preloaded snippets per
189
190
 
190
191
  4. return value to host
191
192
 
192
- 5. discard mrb_state; reset per-invocation state:
193
+ 5. discard the instance; reset per-invocation state:
193
194
  · Handles invalidated
194
195
  · stdout / stderr buffers cleared
195
196
  · memory delta zeroed
@@ -199,6 +200,25 @@ One Sandbox serves many invocations. Service bindings and preloaded snippets per
199
200
 
200
201
  For workloads that must be isolated from each other (one Sandbox per tenant, per student submission, per agent session), construct a fresh `Kobako::Sandbox` per scope — wasmtime's Engine and the compiled Module are cached at process scope, so additional Sandboxes amortize cold-start cost automatically.
201
202
 
203
+ ### Pooling
204
+
205
+ For hosts that serve many short invocations, `Kobako::Pool` keeps a bounded set of warm, identically set-up Sandboxes and hands each one to a single exclusive holder at a time ([`docs/behavior.md`](docs/behavior.md) B-46..B-48). Construction forwards every `Sandbox.new` keyword verbatim; the optional block is the per-Sandbox setup window and runs exactly once per constructed Sandbox.
206
+
207
+ ```ruby
208
+ pool = Kobako::Pool.new(slots: 4) do |sandbox|
209
+ sandbox.define(:KV).bind(:Lookup, ->(key) { redis.get(key) })
210
+ end
211
+
212
+ pool.with { |sandbox| sandbox.eval(%(KV::Lookup.call("user_42"))) }
213
+ ```
214
+
215
+ | Option | Meaning | Default |
216
+ |--------|---------|---------|
217
+ | `slots:` | Upper bound on constructed Sandboxes | required |
218
+ | `checkout_timeout:` | Seconds `#with` waits for a free Sandbox; `nil` waits indefinitely | 5.0 |
219
+
220
+ Sandboxes construct lazily on first demand. `#with` yields a Sandbox with empty output buffers and returns the block's value; at block exit the Sandbox returns to the pool, except a block that raises `Kobako::TrapError` discards its Sandbox and the slot refills by a fresh construction on next demand. A checkout that waits past `checkout_timeout` raises `Kobako::PoolTimeoutError`. There is no teardown verb — a Pool releases everything with its own reachability.
221
+
202
222
  ### Service Blocks
203
223
 
204
224
  A Service method can accept a guest-supplied block via `&blk` and `yield` into it. The block body runs inside the Wasm guest; `break` / `next` / exceptions follow normal Ruby semantics, scoped to the single dispatch. See [`docs/behavior.md`](docs/behavior.md) B-23..B-30.
@@ -248,7 +268,7 @@ This is deliberate, not a leak. Handle IDs run to 2³¹ − 1 per invocation and
248
268
 
249
269
  ### Snippets & Entrypoints
250
270
 
251
- `Sandbox#preload` registers named mruby snippets that replay against the fresh `mrb_state` before every invocation; `Sandbox#run(:Target, *args, **kwargs)` dispatches into a top-level `Object` constant defined by those snippets ([`docs/behavior.md`](docs/behavior.md) B-31..B-33).
271
+ `Sandbox#preload` registers named mruby snippets that replay into every invocation's canonical boot state; `Sandbox#run(:Target, *args, **kwargs)` dispatches into a top-level `Object` constant defined by those snippets ([`docs/behavior.md`](docs/behavior.md) B-31..B-33).
252
272
 
253
273
  ```ruby
254
274
  sandbox = Kobako::Sandbox.new
@@ -262,7 +282,7 @@ sandbox.run(:Greeter, name: "world") # => "hello, world"
262
282
  ```
263
283
  per-invocation replay (every #eval / #run, snippets in insertion order):
264
284
 
265
- fresh mrb_state
285
+ canonical boot state
266
286
 
267
287
  ├──▶ replay :Adder (defines Adder)
268
288
 
@@ -271,7 +291,7 @@ sandbox.run(:Greeter, name: "world") # => "hello, world"
271
291
  └──▶ eval(source) -or- run(:Target, *args, **kwargs)
272
292
 
273
293
 
274
- return value, then mrb_state discarded
294
+ return value, then instance discarded
275
295
  ```
276
296
 
277
297
  `#preload` accepts two payload forms:
@@ -312,15 +332,16 @@ Order-of-magnitude figures on macOS arm64, Ruby 3.4.7, YJIT off. Absolute values
312
332
 
313
333
  | Phase | Cost |
314
334
  |--------------------------------------------------------------|-----------------------|
315
- | First `Sandbox.new` in a fresh process (Engine + Module JIT) | ~600 ms one-time |
316
- | Subsequent `Sandbox.new` (Engine cache warm) | ~125 µs |
317
- | Warm `#eval("nil")` on a reused Sandbox | ~135 µs |
318
- | Warm `#run(:Entrypoint, ...)` dispatch | ~165 µs |
319
- | Service call amortized inside one invocation | ~6.7 µs |
320
- | Snippet replay per invocation | ~7-9 µs each |
321
- | Per additional Sandbox (RSS) | ~570 KB |
322
-
323
- Construct one Sandbox at boot so the ~600 ms JIT cost lands off the request hot path. `ext/` does not release the GVL during wasmtime execution, so wasm work is GVL-serialized: aggregate throughput stays around 7-8k `#eval`/s regardless of Thread count, though Ruby-side `#eval` setup still overlaps. A +10% regression on any of the six SPEC-mandated benchmarks blocks release.
335
+ | First `Sandbox.new` ever for a Guest Binary (Module JIT, then disk-cached) | ~500 ms once per machine |
336
+ | First `Sandbox.new` in a fresh process (`.cwasm` cache warm) | ~5 ms one-time |
337
+ | Subsequent `Sandbox.new` (caches warm) | ~30 µs |
338
+ | Warm `#eval("nil")` on a reused Sandbox | ~73 µs |
339
+ | Warm `#run(:Entrypoint, ...)` dispatch | ~104 µs |
340
+ | Service call amortized inside one invocation | ~6.8 µs |
341
+ | Snippet replay per invocation | ~8 µs each |
342
+ | Per additional idle Sandbox (RSS) | ~1 KB |
343
+
344
+ The Cranelift JIT runs once per machine and gem version — the compiled artifact persists in a `.cwasm` disk cache, so later processes deserialize in milliseconds. An idle Sandbox holds no wasm instance (the canonical boot state is baked into the artifact and instantiated per invocation), which is why a thousand idle tenants cost ~32 MB total. `ext/` does not release the GVL during wasmtime execution, so wasm work is GVL-serialized: aggregate throughput stays around 16k `#eval`/s regardless of Thread count, though Ruby-side `#eval` setup still overlaps. A +10% regression on any of the six SPEC-mandated benchmarks blocks release.
324
345
 
325
346
  Regexp is an opt-in capability gem, excluded from the default binary and the gated set; its throughput is tracked in a separate non-gated characterization (`#10` in [`benchmark/README.md`](benchmark/README.md)). There `=~` (~5 µs/match) costs about 4× `match?` (~1.2 µs), because `=~` eagerly builds the `MatchData` and match globals — prefer `match?` for boolean tests.
326
347
 
data/data/kobako.wasm CHANGED
Binary file
Binary file
Binary file
Binary file
@@ -53,14 +53,9 @@ module Kobako
53
53
  # is reserved for the codec's wire-decode path, where the id is
54
54
  # the only thing the bytes carry.
55
55
  def alloc(object)
56
+ reject_unwrappable!(object)
57
+ ensure_capacity!
56
58
  id = @next_id
57
- cap = Kobako::Handle::MAX_ID
58
- if id > cap
59
- raise HandlerExhaustedError,
60
- "Out of handle allocations: too many host objects were referenced " \
61
- "in a single invocation (limit #{cap})"
62
- end
63
-
64
59
  @entries[id] = object
65
60
  @next_id = id + 1
66
61
  Kobako::Handle.restore(id)
@@ -94,6 +89,30 @@ module Kobako
94
89
 
95
90
  private
96
91
 
92
+ # Refuse to mint a Capability Handle for a reflective gadget
93
+ # ({docs/behavior.md B-43}[link:../../../docs/behavior.md]): a +Binding+ /
94
+ # +Method+ / +UnboundMethod+ would hand the guest a callable proxy onto
95
+ # host reflection (a returned +Binding+ reaches +Binding#eval+). Raising
96
+ # here keeps the rule at the single mint point, so it holds on both the
97
+ # Service-return (B-14) and the +#run+ host→guest auto-wrap (B-34) paths.
98
+ def reject_unwrappable!(object)
99
+ case object
100
+ when Binding, Method, UnboundMethod
101
+ raise SandboxError, "a #{object.class} cannot cross as a Capability Handle"
102
+ end
103
+ end
104
+
105
+ # Guard {#alloc} against issuing an ID past the B-21 cap. Returns +nil+
106
+ # on success; raises +Kobako::HandlerExhaustedError+ at exhaustion.
107
+ def ensure_capacity!
108
+ cap = Kobako::Handle::MAX_ID
109
+ return unless @next_id > cap
110
+
111
+ raise HandlerExhaustedError,
112
+ "Out of handle allocations: too many host objects were referenced " \
113
+ "in a single invocation (limit #{cap})"
114
+ end
115
+
97
116
  # Single source of truth for the "unknown Handle id" raise used by
98
117
  # {#fetch}. Returns +nil+ on success; raises +Kobako::SandboxError+
99
118
  # when +id+ is not currently bound.
@@ -3,7 +3,6 @@
3
3
  require_relative "handles"
4
4
  require_relative "../codec"
5
5
  require_relative "../errors"
6
- require_relative "../transport/request"
7
6
  require_relative "../namespace"
8
7
 
9
8
  module Kobako
@@ -38,6 +37,7 @@ module Kobako
38
37
  @namespaces = {} # : Hash[String, Kobako::Namespace]
39
38
  @handler = handler
40
39
  @sealed = false
40
+ @encoded = nil # : String?
41
41
  end
42
42
 
43
43
  # Declare or retrieve the Namespace named +name+ (idempotent —
@@ -81,14 +81,32 @@ module Kobako
81
81
  # Arrays, so none of the kobako ext types actually fire. Structure:
82
82
  # +[["Namespace", ["MemberA", "MemberB"]], ...]+. Returns a binary
83
83
  # +String+ of msgpack bytes.
84
+ #
85
+ # Once sealed, the bytes are computed once and reused for every
86
+ # subsequent invocation: B-33 seals Service registration (B-07 /
87
+ # B-08) at the first invocation, so the preamble is exactly the
88
+ # bindings that existed at that moment — a bind reaching a
89
+ # +Kobako::Namespace+ after the seal raises +ArgumentError+ (E-45)
90
+ # and never alters Frame 1.
84
91
  def encode
85
- Codec::Encoder.encode(@namespaces.values.map(&:to_preamble))
92
+ return @encoded if @encoded
93
+
94
+ bytes = Codec::Encoder.encode(@namespaces.values.map(&:to_preamble)).freeze
95
+ @encoded = bytes if @sealed
96
+ bytes
86
97
  end
87
98
 
88
- # Mark the registry as sealed. Called by +Sandbox+ on the first
89
- # invocation. After sealing, #define raises ArgumentError. Idempotent.
99
+ # Mark the registry as sealed and propagate the seal to every
100
+ # declared +Kobako::Namespace+
101
+ # ({docs/behavior.md B-33}[link:../../../docs/behavior.md]). Called
102
+ # by +Sandbox+ on the first invocation. After sealing, #define
103
+ # raises ArgumentError (E-18) and +Namespace#bind+ raises
104
+ # ArgumentError (E-45). Idempotent.
90
105
  def seal!
106
+ return self if @sealed
107
+
91
108
  @sealed = true
109
+ @namespaces.each_value(&:seal!)
92
110
  self
93
111
  end
94
112
 
@@ -31,6 +31,7 @@ module Kobako
31
31
 
32
32
  def initialize
33
33
  @entries = [] # : Array[Kobako::Snippet::Source | Kobako::Snippet::Binary]
34
+ @encoded = nil # : String?
34
35
  end
35
36
 
36
37
  # Serialize the registered snippets to wire bytes. Each entry
@@ -42,8 +43,14 @@ module Kobako
42
43
  # carriers — this collection-tier method reads their attributes
43
44
  # externally via +entry_payload+ rather than asking each entry to
44
45
  # self-encode.
46
+ #
47
+ # The bytes are memoized — the table is replayed verbatim on every
48
+ # invocation after B-33 seals it, so Frame 3 never changes between
49
+ # encodes; {#register} drops the memo while the table is still open.
45
50
  def encode
46
- Codec::Encoder.encode(@entries.map { |entry| entry_payload(entry) })
51
+ return @encoded if @encoded
52
+
53
+ @encoded = Codec::Encoder.encode(@entries.map { |entry| entry_payload(entry) }).freeze
47
54
  end
48
55
 
49
56
  # Register one preloaded snippet in either of two forms
@@ -68,6 +75,7 @@ module Kobako
68
75
  # missing keywords, wrong types, malformed +name+ (E-34), or
69
76
  # duplicate +code:+ +name+ (E-33).
70
77
  def register(code: nil, name: nil, binary: nil)
78
+ @encoded = nil
71
79
  if binary
72
80
  raise ArgumentError, "cannot combine binary: with code: / name:" if code || name
73
81
 
@@ -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
data/lib/kobako/codec.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "codec/error"
4
+ require_relative "codec/utils"
5
+ require_relative "codec/factory"
6
+ require_relative "codec/encoder"
7
+ require_relative "codec/decoder"
4
8
 
5
9
  module Kobako
6
10
  # Host-side MessagePack codec for the kobako wire contract — the
@@ -17,15 +21,10 @@ module Kobako
17
21
  # {Decoder} are thin wrappers that register the three kobako-specific
18
22
  # ext types (0x00 Symbol, 0x01 Capability Handle, 0x02 Exception
19
23
  # envelope) on a single +MessagePack::Factory+ instance. The Rust side
20
- # mirrors this layer as the +codec+ module in the +kobako-wasm+ crate;
24
+ # mirrors this layer as the +codec+ module in the +kobako-core+ crate;
21
25
  # the ext-code constants live as module-private values on {Factory}
22
26
  # alongside +codec::EXT_SYMBOL+ / +codec::EXT_HANDLE+ /
23
27
  # +codec::EXT_ERRENV+ on that side.
24
28
  module Codec
25
29
  end
26
30
  end
27
-
28
- require_relative "codec/utils"
29
- require_relative "codec/factory"
30
- require_relative "codec/encoder"
31
- require_relative "codec/decoder"
data/lib/kobako/errors.rb CHANGED
@@ -21,7 +21,7 @@ module Kobako
21
21
  # call that failed and was not rescued inside the
22
22
  # script).
23
23
  #
24
- # A fourth branch sits outside the invocation taxonomy:
24
+ # Two further branches sit outside the invocation taxonomy:
25
25
  #
26
26
  # * {SetupError} — construction layer. Raised by `Kobako::Sandbox.new`
27
27
  # when the wasm runtime cannot be built from the
@@ -29,6 +29,9 @@ module Kobako
29
29
  # ({docs/behavior.md E-40 / E-41}[link:../../docs/behavior.md]).
30
30
  # Not an invocation outcome, so it never passes
31
31
  # through the two-step attribution decision.
32
+ # * {PoolTimeoutError} — pool checkout layer. Raised by `Kobako::Pool#with`
33
+ # when the checkout wait exceeds +checkout_timeout+
34
+ # ({docs/behavior.md E-46}[link:../../docs/behavior.md]).
32
35
  #
33
36
  # Subclasses pinned by docs/behavior.md Error Classes:
34
37
  #
@@ -137,4 +140,11 @@ module Kobako
137
140
  # snippet failures while callers wanting bytecode-specific handling
138
141
  # can `rescue Kobako::BytecodeError` directly.
139
142
  class BytecodeError < SandboxError; end
143
+
144
+ # Pool checkout layer. Raised by +Kobako::Pool#with+ when the checkout
145
+ # wait exceeded the configured +checkout_timeout+ while every slot was
146
+ # held ({docs/behavior.md E-46}[link:../../docs/behavior.md]). No
147
+ # Sandbox state is touched — retrying succeeds as soon as a holder
148
+ # returns its Sandbox.
149
+ class PoolTimeoutError < Error; end
140
150
  end
@@ -18,15 +18,20 @@ module Kobako
18
18
  def initialize(name)
19
19
  @name = name
20
20
  @members = {} # : Hash[String, untyped]
21
+ @sealed = false
21
22
  end
22
23
 
23
24
  # Bind +object+ under +member+ inside this Namespace. +member+ is a
24
25
  # constant-form name as a +Symbol+ or +String+. +object+ is any Ruby
25
26
  # object that responds to the methods guest code will invoke. Returns
26
27
  # +self+ for chaining. Raises +ArgumentError+ when +member+ does not
27
- # match the constant pattern, or a Member of the same name is already
28
- # bound ({docs/behavior.md B-11}[link:../../docs/behavior.md]).
28
+ # match the constant pattern, when a Member of the same name is
29
+ # already bound ({docs/behavior.md B-11}[link:../../docs/behavior.md]),
30
+ # or when the owning Sandbox's first invocation has sealed Service
31
+ # registration ({docs/behavior.md E-45}[link:../../docs/behavior.md]).
29
32
  def bind(member, object)
33
+ raise ArgumentError, "cannot bind after first Sandbox invocation" if @sealed
34
+
30
35
  member_str = validate_member_name!(member)
31
36
  raise ArgumentError, "Member #{@name}::#{member_str} is already bound" if @members.key?(member_str)
32
37
 
@@ -34,6 +39,15 @@ module Kobako
34
39
  self
35
40
  end
36
41
 
42
+ # Mark this Namespace as sealed ({docs/behavior.md B-33}[link:../../docs/behavior.md]).
43
+ # Called by +Kobako::Catalog::Namespaces#seal!+ on the owning
44
+ # Sandbox's first invocation; afterwards {#bind} raises
45
+ # +ArgumentError+ (E-45). Idempotent; returns +self+.
46
+ def seal!
47
+ @sealed = true
48
+ self
49
+ end
50
+
37
51
  # Member lookup; raises +KeyError+ when no Member is registered
38
52
  # under +member+.
39
53
  def fetch(member)
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "sandbox"
5
+
6
+ module Kobako
7
+ # Kobako::Pool — a bounded set of warm, identically set-up Sandboxes
8
+ # handed out one exclusive holder at a time
9
+ # ({docs/behavior.md B-46..B-48}[link:../../docs/behavior.md]).
10
+ #
11
+ # Construction forwards every +Kobako::Sandbox.new+ keyword verbatim
12
+ # and holds the optional block as the per-Sandbox setup hook; a
13
+ # checkout prefers an idle Sandbox and constructs a new one only when
14
+ # none is idle and fewer than +slots+ exist (B-46). +#with+ blocks up
15
+ # to +checkout_timeout+ seconds when every slot is held (E-46), applies
16
+ # the +TrapError+ discard-and-recreate contract at checkin (B-47), and
17
+ # the Pool releases everything with its own reachability — there is no
18
+ # teardown verb (B-48).
19
+ class Pool
20
+ # The +#with+ wait bound applied when +checkout_timeout+ is not given
21
+ # ({docs/behavior.md B-46}[link:../../docs/behavior.md]).
22
+ DEFAULT_CHECKOUT_TIMEOUT_SECONDS = 5.0
23
+
24
+ # Build a Pool of up to +slots+ Sandboxes
25
+ # ({docs/behavior.md B-46}[link:../../docs/behavior.md]). +slots+ is
26
+ # a positive Integer; +checkout_timeout+ bounds the +#with+ wait in
27
+ # seconds (+nil+ waits indefinitely); every other keyword is
28
+ # forwarded verbatim to +Kobako::Sandbox.new+. The optional block
29
+ # runs exactly once per constructed Sandbox — it is the setup window
30
+ # for +#define+ / +#preload+ before that Sandbox's first checkout.
31
+ # No Sandbox is constructed here. Raises +ArgumentError+ for an
32
+ # invalid +slots+ / +checkout_timeout+
33
+ # ({docs/behavior.md E-47}[link:../../docs/behavior.md]).
34
+ def initialize(slots:, checkout_timeout: DEFAULT_CHECKOUT_TIMEOUT_SECONDS, **sandbox_options, &setup)
35
+ validate_slots!(slots)
36
+ @slots = slots
37
+ @checkout_timeout = normalize_checkout_timeout(checkout_timeout)
38
+ @sandbox_options = sandbox_options
39
+ @setup = setup
40
+ @idle = [] # : Array[Kobako::Sandbox]
41
+ @constructed = 0
42
+ @mutex = Mutex.new
43
+ @slot_freed = ConditionVariable.new
44
+ end
45
+
46
+ # Yield one exclusively-held Sandbox to the block and return the
47
+ # block's value ({docs/behavior.md B-47}[link:../../docs/behavior.md]).
48
+ # Blocks while every slot is held; raises +Kobako::PoolTimeoutError+
49
+ # once the wait exceeds +checkout_timeout+
50
+ # ({docs/behavior.md E-46}[link:../../docs/behavior.md]). The Sandbox
51
+ # returns to the pool at block exit — unless the block raised
52
+ # +Kobako::TrapError+, in which case the unrecoverable Sandbox is
53
+ # discarded and its slot refills by a fresh construction on next
54
+ # demand.
55
+ def with
56
+ sandbox = checkout
57
+ begin
58
+ yield sandbox
59
+ rescue TrapError
60
+ release_capacity!
61
+ sandbox = nil
62
+ raise
63
+ ensure
64
+ checkin(sandbox) if sandbox
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # Acquire a Sandbox and hand it over in pre-invocation state — empty
71
+ # output buffers and truncation predicates false (B-47).
72
+ def checkout
73
+ acquire.tap(&:reset_invocation_state!)
74
+ end
75
+
76
+ # The idle-first claim loop (B-46): an idle Sandbox wins, unclaimed
77
+ # capacity constructs, and a full pool waits for a checkin.
78
+ def acquire
79
+ timeout = @checkout_timeout
80
+ deadline = timeout && (monotonic_now + timeout)
81
+ loop do
82
+ action, sandbox = claim_or_wait(deadline)
83
+ return sandbox if action == :idle && sandbox
84
+ return construct_slot if action == :build
85
+ end
86
+ end
87
+
88
+ # Single locked decision point for one claim attempt. Waiting
89
+ # happens inside the lock (so a checkin can wake it); construction
90
+ # happens outside (so a slow setup block never holds the lock) —
91
+ # capacity is reserved here and released by +construct_slot+ on
92
+ # failure.
93
+ def claim_or_wait(deadline)
94
+ @mutex.synchronize do
95
+ return [:idle, @idle.pop] unless @idle.empty?
96
+
97
+ if @constructed < @slots
98
+ @constructed += 1
99
+ return [:build, nil]
100
+ end
101
+
102
+ await_slot!(deadline)
103
+ [:retry, nil]
104
+ end
105
+ end
106
+
107
+ # Wait for a checkin or freed capacity; raises
108
+ # +Kobako::PoolTimeoutError+ once +deadline+ has passed (E-46). Must
109
+ # run while holding +@mutex+.
110
+ def await_slot!(deadline)
111
+ remaining = deadline && (deadline - monotonic_now)
112
+ if remaining && remaining <= 0
113
+ raise PoolTimeoutError,
114
+ "no Sandbox returned within #{@checkout_timeout}s: all #{@slots} slots are held"
115
+ end
116
+
117
+ @slot_freed.wait(@mutex, remaining)
118
+ end
119
+
120
+ # Construct and set up one pooled Sandbox against the capacity
121
+ # reserved by +claim_or_wait+. Construction and setup-block errors
122
+ # propagate to the checkout caller unchanged (B-46); the reserved
123
+ # capacity is released so a later checkout can retry.
124
+ def construct_slot
125
+ done = false
126
+ sandbox = Sandbox.new(**@sandbox_options)
127
+ @setup&.call(sandbox)
128
+ done = true
129
+ sandbox
130
+ ensure
131
+ release_capacity! unless done
132
+ end
133
+
134
+ # Return a Sandbox to the idle list and wake one waiting checkout.
135
+ def checkin(sandbox)
136
+ @mutex.synchronize do
137
+ @idle.push(sandbox)
138
+ @slot_freed.signal
139
+ end
140
+ end
141
+
142
+ # Give back reserved-but-unfilled capacity — a failed construction or
143
+ # a discarded Sandbox — and wake one waiting checkout to claim it.
144
+ def release_capacity!
145
+ @mutex.synchronize do
146
+ @constructed -= 1
147
+ @slot_freed.signal
148
+ end
149
+ end
150
+
151
+ # The wait deadline runs on the monotonic clock so a wall-clock jump
152
+ # cannot stretch or cut the checkout wait.
153
+ def monotonic_now
154
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+ end
156
+
157
+ # E-47 pre-flight for +slots+ — no coercion, a positive Integer is
158
+ # the only accepted shape.
159
+ def validate_slots!(slots)
160
+ return if slots.is_a?(Integer) && slots.positive?
161
+
162
+ raise ArgumentError, "slots must be a positive Integer, got #{slots.inspect}"
163
+ end
164
+
165
+ # Coerce +checkout_timeout+ into the Float seconds the wait loop
166
+ # consumes, or +nil+ to wait indefinitely — the E-39 normalisation
167
+ # idiom applied to E-47.
168
+ def normalize_checkout_timeout(checkout_timeout)
169
+ return nil if checkout_timeout.nil?
170
+ unless checkout_timeout.is_a?(Numeric)
171
+ raise ArgumentError, "checkout_timeout must be Numeric or nil, got #{checkout_timeout.inspect}"
172
+ end
173
+
174
+ seconds = checkout_timeout.to_f
175
+ unless seconds.positive? && seconds.finite?
176
+ raise ArgumentError, "checkout_timeout must be > 0 and finite (got #{checkout_timeout})"
177
+ end
178
+
179
+ seconds
180
+ end
181
+ end
182
+ end
@@ -206,6 +206,22 @@ module Kobako
206
206
  end
207
207
  end
208
208
 
209
+ # Reset all per-invocation observable state to its pre-invocation
210
+ # sentinels — both per-channel captures
211
+ # ({docs/behavior.md B-05}[link:../../docs/behavior.md]) and the
212
+ # per-last-invocation usage record
213
+ # ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Shared by
214
+ # +#initialize+ (first-time setup) and +#begin_invocation!+
215
+ # (between-invocation reset) so both paths agree on what
216
+ # "pre-invocation state" means; +Kobako::Pool+ calls it at checkout
217
+ # so a pooled Sandbox hands over empty output buffers
218
+ # ({docs/behavior.md B-47}[link:../../docs/behavior.md]).
219
+ def reset_invocation_state!
220
+ @stdout_capture = Capture::EMPTY
221
+ @stderr_capture = Capture::EMPTY
222
+ @usage = Usage::EMPTY
223
+ end
224
+
209
225
  private
210
226
 
211
227
  # Configure the +Runtime+'s host↔guest dispatch wiring
@@ -237,20 +253,6 @@ module Kobako
237
253
  reset_invocation_state!
238
254
  end
239
255
 
240
- # Reset all per-invocation observable state to its pre-invocation
241
- # sentinels — both per-channel captures
242
- # ({docs/behavior.md B-05}[link:../../docs/behavior.md]) and the
243
- # per-last-invocation usage record
244
- # ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Shared by
245
- # +#initialize+ (first-time setup) and +#begin_invocation!+
246
- # (between-invocation reset) so both paths agree on what
247
- # "pre-invocation state" means.
248
- def reset_invocation_state!
249
- @stdout_capture = Capture::EMPTY
250
- @stderr_capture = Capture::EMPTY
251
- @usage = Usage::EMPTY
252
- end
253
-
254
256
  # Read the per-last-invocation +wall_time+ and +memory_peak+ from
255
257
  # the ext and wrap them as a +Kobako::Usage+ value object
256
258
  # ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Runs in
@@ -46,13 +46,28 @@ module Kobako
46
46
  # metaprogramming surface (+send+, +public_send+, +instance_eval+,
47
47
  # +method+, +tap+, +instance_variable_get+, ...) rather than Service
48
48
  # behaviour. A guest-supplied method name resolving to one of these is
49
- # rejected: the security contract is that only methods the bound object
50
- # itself defines are reachable, and +public_send(:send, ...)+ would
51
- # otherwise let a guest pivot through +send+ into the private
52
- # +Kernel#eval+ / +#system+ surface (host RCE).
49
+ # rejected ({docs/behavior.md B-42}[link:../../../docs/behavior.md]):
50
+ # only methods the bound object itself exposes as Service behaviour are
51
+ # reachable, and +public_send(:send, ...)+ would otherwise let a guest
52
+ # pivot through +send+ into the private +Kernel#eval+ / +#system+
53
+ # surface (host RCE).
53
54
  META_OWNERS = [BasicObject, Kernel, Object, Module, Class].freeze
54
55
  private_constant :META_OWNERS
55
56
 
57
+ # Callable gadget types whose own public methods are reflection surface
58
+ # (+Proc#binding+ reaches +Binding#eval+, +Method#receiver+ / +#unbind+
59
+ # hand back the underlying object) rather than Service behaviour. Only
60
+ # {CALLABLE_ALLOW} is reachable on a target of these types; a bound
61
+ # lambda stays invocable, its reflective surface does not (B-42).
62
+ GADGET_OWNERS = [Proc, Method, UnboundMethod, Binding].freeze
63
+ private_constant :GADGET_OWNERS
64
+
65
+ # The sole methods reachable on a {GADGET_OWNERS} target: invoking it
66
+ # (+call+ / +[]+ / +yield+) and the harmless +arity+ / +lambda?+
67
+ # describers that aid guest-side debugging.
68
+ CALLABLE_ALLOW = %i[call [] yield arity lambda?].freeze
69
+ private_constant :CALLABLE_ALLOW
70
+
56
71
  # Dispatch a single transport request and return the encoded
57
72
  # Response bytes ({docs/behavior.md B-12}[link:../../../docs/behavior.md]).
58
73
  # Invoked from the +Runtime#on_dispatch+ Proc that
@@ -131,14 +146,18 @@ module Kobako
131
146
  end
132
147
 
133
148
  # Guard the +public_send+ below against ambient reflection methods
134
- # (see {META_OWNERS}). A concretely-defined public method whose owner
135
- # is a meta module is rejected; a name with no concrete public method
136
- # is allowed only when the target opts into it via +respond_to?+
137
- # (dynamic +method_missing+ Services), since the dangerous meta methods
138
- # are all concretely defined and therefore never reach that branch.
149
+ # ({docs/behavior.md B-42}[link:../../../docs/behavior.md]). A public
150
+ # method whose owner is a {META_OWNERS} or {GADGET_OWNERS} module is
151
+ # rejected, except {CALLABLE_ALLOW} on a gadget target (a bound lambda
152
+ # stays invocable). A name with no concrete public method is allowed
153
+ # only when the target opts into it via +respond_to?+ (dynamic
154
+ # +method_missing+ Services), since the dangerous methods are all
155
+ # concretely defined and therefore never reach that branch.
139
156
  def reject_meta_method!(target, name)
140
157
  owner = target.public_method(name).owner
141
- return unless META_OWNERS.include?(owner)
158
+ gadget = GADGET_OWNERS.include?(owner)
159
+ return unless META_OWNERS.include?(owner) || gadget
160
+ return if gadget && CALLABLE_ALLOW.include?(name)
142
161
 
143
162
  raise UndefinedTargetError, "method #{name.inspect} is not a Service method"
144
163
  rescue NameError
@@ -5,16 +5,8 @@ require_relative "../codec"
5
5
 
6
6
  module Kobako
7
7
  # See lib/kobako/transport.rb for the umbrella module doc; this file
8
- # owns the Request value object and its +#encode+ / +.decode+ codec,
9
- # plus the +STATUS_OK+ / +STATUS_ERROR+ constants shared with Response.
8
+ # owns the Request value object and its +#encode+ / +.decode+ codec.
10
9
  module Transport
11
- # ---------------- Response status bytes (docs/wire-contract.md § Response Shape) ---
12
-
13
- # Response variant marker for the success branch.
14
- STATUS_OK = 0
15
- # Response variant marker for the fault branch.
16
- STATUS_ERROR = 1
17
-
18
10
  # Value object for a single guest-initiated Transport Request
19
11
  # ({docs/wire-codec.md Envelope Encoding → Request}[link:../../../docs/wire-codec.md]).
20
12
  #
@@ -2,12 +2,19 @@
2
2
 
3
3
  require_relative "../codec"
4
4
  require_relative "../fault"
5
- require_relative "request"
6
5
 
7
6
  module Kobako
8
7
  # See lib/kobako/transport.rb for the umbrella module doc; this file
9
- # owns the Response value object and its +#encode+ / +.decode+ codec.
8
+ # owns the Response value object and its +#encode+ / +.decode+ codec,
9
+ # plus the +STATUS_OK+ / +STATUS_ERROR+ status bytes.
10
10
  module Transport
11
+ # ---------------- Response status bytes (docs/wire-contract.md § Response Shape) ---
12
+
13
+ # Response variant marker for the success branch.
14
+ STATUS_OK = 0
15
+ # Response variant marker for the fault branch.
16
+ STATUS_ERROR = 1
17
+
11
18
  # Value object for a single host-side Transport Response
12
19
  # ({docs/wire-codec.md Envelope Encoding → Response}[link:../../../docs/wire-codec.md]).
13
20
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- VERSION = "0.9.1"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/kobako.rb CHANGED
@@ -15,3 +15,4 @@ require_relative "kobako/catalog"
15
15
  require_relative "kobako/runtime"
16
16
  require_relative "kobako/snapshot"
17
17
  require_relative "kobako/sandbox"
18
+ require_relative "kobako/pool"
@@ -7,8 +7,7 @@
7
7
  "component": "kobako",
8
8
  "include-component-in-tag": false,
9
9
  "release-type": "ruby",
10
- "exclude-paths": ["wasm"],
11
- "release-as": "0.9.1"
10
+ "exclude-paths": ["wasm"]
12
11
  },
13
12
  "wasm/kobako-core": {
14
13
  "component": "kobako-core",
@@ -74,13 +73,28 @@
74
73
  "path": "/wasm/kobako-regexp/README.md"
75
74
  }
76
75
  ]
76
+ },
77
+ "wasm/kobako-baker": {
78
+ "component": "kobako-baker",
79
+ "release-type": "rust",
80
+ "extra-files": [
81
+ {
82
+ "type": "toml",
83
+ "path": "/wasm/kobako-baker/Cargo.lock",
84
+ "jsonpath": "$.package[?(@.name=='kobako-baker')].version"
85
+ },
86
+ {
87
+ "type": "generic",
88
+ "path": "/wasm/kobako-baker/README.md"
89
+ }
90
+ ]
77
91
  }
78
92
  },
79
93
  "plugins": [
80
94
  {
81
95
  "type": "linked-versions",
82
96
  "groupName": "kobako guest crates",
83
- "components": ["kobako-core", "kobako-rs", "kobako-io", "kobako-regexp"]
97
+ "components": ["kobako-core", "kobako-rs", "kobako-io", "kobako-regexp", "kobako-baker"]
84
98
  }
85
99
  ],
86
100
  "extra-files": [
@@ -13,6 +13,10 @@ module Kobako
13
13
 
14
14
  private
15
15
 
16
+ def reject_unwrappable!: (untyped object) -> void
17
+
18
+ def ensure_capacity!: () -> void
19
+
16
20
  def require_bound!: (Integer id) -> void
17
21
  end
18
22
  end
@@ -52,4 +52,7 @@ module Kobako
52
52
 
53
53
  class BytecodeError < SandboxError
54
54
  end
55
+
56
+ class PoolTimeoutError < Error
57
+ end
55
58
  end
@@ -8,6 +8,8 @@ module Kobako
8
8
 
9
9
  def bind: (Symbol | String member, untyped object) -> self
10
10
 
11
+ def seal!: () -> self
12
+
11
13
  def fetch: (Symbol | String member) -> untyped
12
14
 
13
15
  def to_preamble: () -> [String, Array[String]]
@@ -0,0 +1,44 @@
1
+ module Kobako
2
+ class Pool
3
+ DEFAULT_CHECKOUT_TIMEOUT_SECONDS: Float
4
+
5
+ @slots: Integer
6
+ @checkout_timeout: Float?
7
+ @sandbox_options: Hash[Symbol, untyped]
8
+ @setup: ^(Kobako::Sandbox) -> void | nil
9
+ @idle: Array[Kobako::Sandbox]
10
+ @constructed: Integer
11
+ @mutex: Thread::Mutex
12
+ @slot_freed: Thread::ConditionVariable
13
+
14
+ def initialize: (
15
+ slots: Integer,
16
+ ?checkout_timeout: (Float | Integer)?,
17
+ **untyped sandbox_options
18
+ ) ?{ (Kobako::Sandbox) -> void } -> void
19
+
20
+ def with: [T] () { (Kobako::Sandbox) -> T } -> T
21
+
22
+ private
23
+
24
+ def checkout: () -> Kobako::Sandbox
25
+
26
+ def acquire: () -> Kobako::Sandbox
27
+
28
+ def claim_or_wait: (Float? deadline) -> [Symbol, Kobako::Sandbox?]
29
+
30
+ def await_slot!: (Float? deadline) -> void
31
+
32
+ def construct_slot: () -> Kobako::Sandbox
33
+
34
+ def checkin: (Kobako::Sandbox sandbox) -> void
35
+
36
+ def release_capacity!: () -> void
37
+
38
+ def monotonic_now: () -> Float
39
+
40
+ def validate_slots!: (untyped slots) -> void
41
+
42
+ def normalize_checkout_timeout: ((Float | Integer)? checkout_timeout) -> Float?
43
+ end
44
+ end
@@ -38,12 +38,12 @@ module Kobako
38
38
 
39
39
  def eval: (String code) -> untyped
40
40
 
41
+ def reset_invocation_state!: () -> void
42
+
41
43
  private
42
44
 
43
45
  def install_dispatch_proc!: () -> void
44
46
 
45
- def reset_invocation_state!: () -> void
46
-
47
47
  def begin_invocation!: () -> void
48
48
 
49
49
  def read_usage!: () -> void
@@ -8,6 +8,10 @@ module Kobako
8
8
 
9
9
  META_OWNERS: Array[Module]
10
10
 
11
+ GADGET_OWNERS: Array[Module]
12
+
13
+ CALLABLE_ALLOW: Array[Symbol]
14
+
11
15
  def self?.dispatch: (String request_bytes, Kobako::Catalog::Namespaces namespaces, Kobako::Catalog::Handles handler, ^(String) -> String yield_to_guest) -> String
12
16
 
13
17
  def self?.resolve_call_args: (Kobako::Transport::Request request, Kobako::Catalog::Handles handler) -> [Array[untyped], Hash[Symbol, untyped]]
@@ -1,8 +1,5 @@
1
1
  module Kobako
2
2
  module Transport
3
- STATUS_OK: Integer
4
- STATUS_ERROR: Integer
5
-
6
3
  class Request < Data
7
4
  attr_reader target: String | Kobako::Handle
8
5
  attr_reader method_name: String
@@ -1,5 +1,8 @@
1
1
  module Kobako
2
2
  module Transport
3
+ STATUS_OK: Integer
4
+ STATUS_ERROR: Integer
5
+
3
6
  class Response < Data
4
7
  attr_reader status: Integer
5
8
  attr_reader payload: untyped
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kobako
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.10.0
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Aotokitsuruya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-11 00:00:00.000000000 Z
11
+ date: 2026-06-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: msgpack
@@ -59,6 +59,7 @@ files:
59
59
  - lib/kobako/namespace.rb
60
60
  - lib/kobako/outcome.rb
61
61
  - lib/kobako/outcome/panic.rb
62
+ - lib/kobako/pool.rb
62
63
  - lib/kobako/runtime.rb
63
64
  - lib/kobako/sandbox.rb
64
65
  - lib/kobako/sandbox_options.rb
@@ -96,6 +97,7 @@ files:
96
97
  - sig/kobako/namespace.rbs
97
98
  - sig/kobako/outcome.rbs
98
99
  - sig/kobako/outcome/panic.rbs
100
+ - sig/kobako/pool.rbs
99
101
  - sig/kobako/runtime.rbs
100
102
  - sig/kobako/sandbox.rbs
101
103
  - sig/kobako/sandbox_options.rbs