kobako 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +31 -0
  4. data/Cargo.lock +6 -5
  5. data/README.md +4 -1
  6. data/crates/kobako-runtime/CHANGELOG.md +14 -0
  7. data/crates/kobako-runtime/Cargo.toml +1 -1
  8. data/crates/kobako-runtime/README.md +1 -1
  9. data/crates/kobako-wasmtime/CHANGELOG.md +14 -0
  10. data/crates/kobako-wasmtime/Cargo.toml +2 -2
  11. data/crates/kobako-wasmtime/README.md +1 -1
  12. data/data/kobako.wasm +0 -0
  13. data/ext/kobako/Cargo.toml +6 -2
  14. data/ext/kobako/src/runtime/bridge.rs +30 -24
  15. data/ext/kobako/src/runtime/gvl.rs +114 -0
  16. data/ext/kobako/src/runtime.rs +211 -190
  17. data/lib/kobako/catalog/extensions.rb +50 -43
  18. data/lib/kobako/catalog/handles.rb +7 -16
  19. data/lib/kobako/catalog/services.rb +22 -36
  20. data/lib/kobako/catalog/snippets.rb +11 -10
  21. data/lib/kobako/context.rb +202 -0
  22. data/lib/kobako/errors.rb +26 -4
  23. data/lib/kobako/execution.rb +52 -0
  24. data/lib/kobako/extension.rb +24 -10
  25. data/lib/kobako/pool.rb +1 -7
  26. data/lib/kobako/sandbox.rb +43 -182
  27. data/lib/kobako/sandbox_options.rb +23 -2
  28. data/lib/kobako/transport/dispatcher.rb +16 -15
  29. data/lib/kobako/transport/yielder.rb +1 -1
  30. data/lib/kobako/unresolved.rb +17 -0
  31. data/lib/kobako/usage.rb +6 -6
  32. data/lib/kobako/version.rb +1 -1
  33. data/lib/kobako.rb +1 -0
  34. data/release-please-config.json +2 -1
  35. data/sig/kobako/catalog/extensions.rbs +4 -6
  36. data/sig/kobako/catalog/handles.rbs +0 -2
  37. data/sig/kobako/catalog/services.rbs +3 -3
  38. data/sig/kobako/catalog/snippets.rbs +2 -0
  39. data/sig/kobako/context.rbs +36 -0
  40. data/sig/kobako/errors.rbs +12 -3
  41. data/sig/kobako/execution.rbs +24 -0
  42. data/sig/kobako/extension.rbs +4 -2
  43. data/sig/kobako/runtime.rbs +37 -9
  44. data/sig/kobako/sandbox.rbs +5 -24
  45. data/sig/kobako/sandbox_options.rbs +9 -2
  46. data/sig/kobako/transport/dispatcher.rbs +3 -3
  47. data/sig/kobako/unresolved.rbs +4 -0
  48. metadata +8 -1
@@ -27,17 +27,31 @@ module Kobako
27
27
  # Kobako::Extension::Backend — the host attachment of an Extension,
28
28
  # pairing +path+ (the constant path the backend binds at, single-segment
29
29
  # +"File"+ or nested +"MyApp::Store"+, spelling the guest constant the
30
- # idiom routes to) with +provider+ (the source of the bound object).
30
+ # idiom routes to) with the source of the bound object, declared by
31
+ # explicit keyword:
31
32
  #
32
- # A +provider+ that is not itself callable is the bound object, resolved
33
- # once for the Sandbox's life; a callable provider is invoked once per
34
- # invocation to yield that invocation's object, so a fresh object backs
35
- # the path every invocation. Callability is the sole discriminator — a
36
- # fixed backend that is itself callable is supplied through a
37
- # non-callable wrapper. A callable provider that raises propagates its
38
- # exception to the invocation caller and leaves the guest unrun; the
39
- # next invocation resolves it afresh.
40
- class Backend < Data.define(:path, :provider)
33
+ # * +object:+ — a static object, fixed for the Sandbox's life.
34
+ # * +provider:+ — a no-argument callable invoked once per invocation to
35
+ # yield that invocation's object, so a fresh object backs the path
36
+ # every invocation. A provider that raises propagates its exception to
37
+ # the invocation caller and leaves the guest unrun; the next
38
+ # invocation resolves it afresh.
39
+ # * neither — a fillable, defaulting to +Kobako::Unresolved+ until the
40
+ # host supplies the invocation's object.
41
+ #
42
+ # The kind is chosen by keyword, never inferred from whether the value is
43
+ # callable, so a static object that is itself callable is expressed
44
+ # directly with +object:+. Giving both +object:+ and +provider:+ raises
45
+ # +ArgumentError+.
46
+ class Backend < Data.define(:path, :object, :provider)
47
+ def initialize(path:, object: nil, provider: nil)
48
+ if !object.nil? && !provider.nil?
49
+ raise ArgumentError,
50
+ "Extension::Backend accepts object: or provider:, not both"
51
+ end
52
+
53
+ super
54
+ end
41
55
  end
42
56
 
43
57
  # +backend+ and +depends_on+ default to absent so the common
data/lib/kobako/pool.rb CHANGED
@@ -47,7 +47,7 @@ module Kobako
47
47
  # discarded and its slot refills by a fresh construction on next
48
48
  # demand.
49
49
  def with
50
- sandbox = checkout
50
+ sandbox = acquire
51
51
  begin
52
52
  yield sandbox
53
53
  rescue TrapError
@@ -61,12 +61,6 @@ module Kobako
61
61
 
62
62
  private
63
63
 
64
- # Acquire a Sandbox and hand it over in pre-invocation state — empty
65
- # output buffers and truncation predicates false.
66
- def checkout
67
- acquire.tap(&:reset_invocation_state!)
68
- end
69
-
70
64
  # The idle-first claim loop: an idle Sandbox wins, unclaimed
71
65
  # capacity constructs, and a full pool waits for a checkin.
72
66
  def acquire
@@ -2,37 +2,33 @@
2
2
 
3
3
  require "forwardable"
4
4
 
5
- require_relative "capture"
6
- require_relative "codec"
7
5
  require_relative "errors"
8
- require_relative "outcome"
6
+ require_relative "unresolved"
9
7
  require_relative "sandbox_options"
10
- require_relative "usage"
11
8
  require_relative "transport"
12
9
  require_relative "catalog"
10
+ require_relative "context"
13
11
 
14
12
  module Kobako
15
13
  # Kobako::Sandbox — the user-facing entry point for executing guest mruby
16
14
  # scripts inside a wasmtime-hosted Wasm module.
17
15
  #
18
- # The Sandbox owns the +Kobako::Runtime+, the per-Sandbox
19
- # +Kobako::Catalog::Handles+, the per-instance
20
- # +Kobako::Catalog::Services+ (which receives the +Catalog::Handles+ by
21
- # injection so guest→host dispatch and host→guest auto-wrap share one
22
- # allocator), and the dispatch +Proc+ / +yield_to_guest+ lambda installed
23
- # on the Runtime via +Runtime#on_dispatch=+. The underlying wasmtime Engine
24
- # and compiled Module are cached at process scope by the native ext and
25
- # never surface to Ruby — constructing many Sandboxes amortises both costs
16
+ # The Sandbox owns the reusable configuration — the +Kobako::Runtime+ and
17
+ # the +Kobako::Catalog::Services+ / +Catalog::Snippets+ / +Catalog::Extensions+
18
+ # registries. Each +#eval+ / +#run+ seals that config on the first call and
19
+ # drives one guest invocation through a fresh +Kobako::Context+ — its own
20
+ # Handle table, dispatch +Proc+, captures, and usage — so the reusable
21
+ # Sandbox holds no per-invocation state. The underlying wasmtime Engine and
22
+ # compiled Module are cached at process scope by the native ext and never
23
+ # surface to Ruby — constructing many Sandboxes amortises both costs
26
24
  # automatically.
27
25
  #
28
- # Output capture policy: the
29
- # per-channel cap (+stdout_limit+ / +stderr_limit+) is enforced inside the
30
- # WASI pipe — the host buffer stops growing at the cap, subsequent guest
31
- # writes on that channel fail or are dropped, and +#run+ still returns
32
- # normally. +#stdout+ / +#stderr+ return the captured prefix as a UTF-8
33
- # String; the byte content never carries a truncation sentinel.
34
- # +#stdout_truncated?+ / +#stderr_truncated?+ are the only way to observe
35
- # that the cap was hit.
26
+ # A run's observables — the captured +#stdout+ / +#stderr+ (bounded by
27
+ # +stdout_limit+ / +stderr_limit+, enforced inside the WASI pipe) with their
28
+ # +#stdout_truncated?+ / +#stderr_truncated?+ predicates, and +#usage+ — live
29
+ # on the +Kobako::Execution+ each +#eval+ / +#run+ returns, or on the one its
30
+ # raised error carries. The Sandbox itself keeps none of them, so nothing a
31
+ # run observes carries into the next one.
36
32
  class Sandbox
37
33
  extend Forwardable
38
34
 
@@ -40,39 +36,7 @@ module Kobako
40
36
 
41
37
  # Per-option accessors forward to the immutable +SandboxOptions+ Value
42
38
  # Object so the Host App still reads them off Sandbox directly.
43
- def_delegators :@options, :timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile
44
-
45
- # Returns the bytes the guest wrote to stdout during the most recent
46
- # invocation as a UTF-8 String, clipped at +stdout_limit+. Empty before
47
- # any invocation; the byte content never contains a truncation sentinel,
48
- # so use +#stdout_truncated?+ to observe overflow. Populated on every
49
- # outcome — including a rescued +TrapError+, after which it holds the
50
- # bytes written before the trap fired — mirroring +#usage+.
51
- def stdout = @stdout_capture.bytes
52
-
53
- # Returns the bytes the guest wrote to stderr during the most recent
54
- # invocation as a UTF-8 String, clipped at +stderr_limit+. Empty before
55
- # any invocation. Mirror of +#stdout+.
56
- def stderr = @stderr_capture.bytes
57
-
58
- # Returns +true+ iff stdout capture during the most recent invocation
59
- # exceeded +stdout_limit+. Resets to +false+ at the start of the next
60
- # invocation.
61
- def stdout_truncated? = @stdout_capture.truncated?
62
-
63
- # Returns +true+ iff stderr capture during the most recent invocation
64
- # exceeded +stderr_limit+. Mirror of +#stdout_truncated?+.
65
- def stderr_truncated? = @stderr_capture.truncated?
66
-
67
- # Returns the +Kobako::Usage+ value object for the most recent
68
- # invocation. Carries +wall_time+ (Float seconds the guest export call spent
69
- # inside wasmtime) and +memory_peak+ (Integer bytes, high-water of
70
- # the per-invocation +memory.grow+ delta past the entry-time
71
- # baseline). Returns +Kobako::Usage::EMPTY+ before any invocation;
72
- # populated on every outcome — including +TrapError+ — so the Host
73
- # App can read it after rescuing a trap to diagnose budget
74
- # consumption.
75
- attr_reader :usage
39
+ def_delegators :@options, :timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile, :gvl
76
40
 
77
41
  # Build a fresh Sandbox.
78
42
  #
@@ -91,13 +55,10 @@ module Kobako
91
55
  def initialize(wasm_path: nil, **)
92
56
  @wasm_path = wasm_path || Kobako::Runtime.default_path
93
57
  @options = SandboxOptions.new(**)
94
- @handler = Catalog::Handles.new
95
- @services = Kobako::Catalog::Services.new(handler: @handler)
58
+ @services = Kobako::Catalog::Services.new
96
59
  @snippets = Catalog::Snippets.new
97
60
  @extensions = Catalog::Extensions.new
98
61
  @runtime = build_runtime!
99
- install_dispatch_proc!
100
- reset_invocation_state!
101
62
  end
102
63
 
103
64
  # Bind +object+ as the Service reachable at +path+ — a Symbol or
@@ -105,11 +66,17 @@ module Kobako
105
66
  # (+"MyService::KV"+ or a top-level +"File"+). Returns +self+ for
106
67
  # chaining.
107
68
  #
69
+ # Called with only a +path+, it declares a fillable Service:
70
+ # +bind(path)+ reserves the path for +Kobako::Unresolved+, so the guest
71
+ # sees the constant while the host defers the object it stands for. A
72
+ # guest dispatch to an unfilled fillable surfaces as
73
+ # +Kobako::ServiceError+ when left unrescued.
74
+ #
108
75
  # Raises +ArgumentError+ when a segment is malformed, when +path+
109
76
  # collides with an existing binding (a name is a bound Service or a
110
77
  # grouping prefix, never both), or when called after the first
111
78
  # invocation has sealed Service registration.
112
- def bind(path, object)
79
+ def bind(path, object = Unresolved)
113
80
  @services.bind(path, object)
114
81
  self
115
82
  end
@@ -175,11 +142,9 @@ module Kobako
175
142
  # deserialized result. The first invocation seals the Service registry
176
143
  # and snippet table. Runtime errors follow the same three-class
177
144
  # taxonomy as +#eval+.
178
- def run(target, *args, **kwargs)
145
+ def run(target, *args, **kwargs, &block)
179
146
  run_envelope = Transport::Run.new(entrypoint: target, args: args, kwargs: kwargs)
180
- invoke!(:run) do
181
- @runtime.run(@services.encode, @snippets.encode, run_envelope.encode(@handler))
182
- end
147
+ new_invocation.run(run_envelope, &block)
183
148
  end
184
149
 
185
150
  # Execute a guest mruby source string in a fresh +mrb_state+. +code+ is
@@ -203,24 +168,10 @@ module Kobako
203
168
  # (including when +code+ is +nil+ or not a String, or when a preloaded
204
169
  # snippet's replay raises); +Kobako::ServiceError+ on an unrescued
205
170
  # Service capability failure.
206
- def eval(code)
171
+ def eval(code, &block)
207
172
  raise SandboxError, "code must be a String, got #{code.class}" unless code.is_a?(String)
208
173
 
209
- invoke!(:eval) do
210
- @runtime.eval(@services.encode, code.b, @snippets.encode)
211
- end
212
- end
213
-
214
- # Reset all per-invocation observable state to its pre-invocation
215
- # sentinels — both per-channel captures and the per-last-invocation
216
- # usage record. Shared by +#initialize+ (first-time setup) and
217
- # +#begin_invocation!+ (between-invocation reset) so both paths agree on
218
- # what "pre-invocation state" means; +Kobako::Pool+ calls it at checkout
219
- # so a pooled Sandbox hands over empty output buffers.
220
- def reset_invocation_state!
221
- @stdout_capture = Capture::EMPTY
222
- @stderr_capture = Capture::EMPTY
223
- @usage = Usage::EMPTY
174
+ new_invocation.eval(code, &block)
224
175
  end
225
176
 
226
177
  private
@@ -231,119 +182,29 @@ module Kobako
231
182
  # runtime that cannot honor the request never runs guest code.
232
183
  def build_runtime!
233
184
  runtime = Kobako::Runtime.from_path(@wasm_path, @options.timeout, @options.memory_limit,
234
- @options.stdout_limit, @options.stderr_limit, @options.profile)
185
+ @options.stdout_limit, @options.stderr_limit, @options.profile,
186
+ @options.gvl)
235
187
  @options.enforce_floor!(runtime.profile)
236
188
  runtime
237
189
  end
238
190
 
239
- # Configure the +Runtime+'s host↔guest dispatch wiring. Registers a
240
- # dispatch +Proc+ that routes guest→host calls through the stateless
241
- # +Transport::Dispatcher+, capturing +@services+ / +@handler+ in the
242
- # closure. The ext hands the +Proc+ a per-dispatch +guest_yielder+ — a
243
- # +String → String+ callable that re-enters the in-flight guest to run a
244
- # yielded block — which the +Dispatcher+ forwards to the +Transport::Yielder+
245
- # it builds for the call. Registered once at construction time so the
246
- # wasm ext callback can fire without further setup.
247
- def install_dispatch_proc!
248
- @runtime.on_dispatch = lambda do |request_bytes, guest_yielder|
249
- Transport::Dispatcher.dispatch(request_bytes, @services, @handler, guest_yielder)
250
- end
191
+ # Seal the config on the first invocation and return a fresh
192
+ # per-invocation +Context+ for the verb to drive. The Context owns this
193
+ # run's Handle table, resolved Extension backends, captures, and usage, so
194
+ # no per-invocation state is written back onto the shared config.
195
+ def new_invocation
196
+ begin_invocation!
197
+ Context.new(runtime: @runtime, services: @services, snippets: @snippets, extensions: @extensions)
251
198
  end
252
199
 
253
- # Per-invocation prologue. Seals the Service / snippet / Extension
254
- # registries on first call (idempotent — asserting Extension
255
- # dependencies then), refreshes each callable Extension backend to this
256
- # invocation's fresh object, and zeros the per-invocation capability
257
- # state — capture buffers, truncation predicates, and the
258
- # +Catalog::Handles+ counter — before the guest runs. The
259
- # +Catalog::Handles+ itself is held as +@handler+ and never exposed
260
- # beyond this class — it is not part of the Host App's surface.
200
+ # Per-invocation prologue on the config tier: seals the Service / snippet /
201
+ # Extension registries on the first call (idempotent — asserting Extension
202
+ # dependencies then). Per-invocation provider resolution and observable
203
+ # state live on the +Context+, not here.
261
204
  def begin_invocation!
262
205
  @services.seal!
206
+ @snippets.seal!
263
207
  @extensions.seal!
264
- @extensions.refresh_backends!(@services)
265
- @handler.reset!
266
- reset_invocation_state!
267
- end
268
-
269
- # Read the per-last-invocation +wall_time+ and +memory_peak+ from
270
- # the ext and wrap them as a +Kobako::Usage+ value object. Runs in
271
- # the +invoke!+ +ensure+ block so the usage record is populated on
272
- # every outcome — value return, +Kobako::TrapError+ (including
273
- # +TimeoutError+ / +MemoryLimitError+), +Kobako::SandboxError+,
274
- # and +Kobako::ServiceError+. +Runtime#usage+ is the single source for
275
- # both paths: the figures are stashed in the ext on every outcome, so
276
- # the readout here also covers the trap path, where +Runtime#eval+ /
277
- # +#run+ raise instead of returning outcome bytes.
278
- #
279
- # The ext-side contract is positional: +Runtime#usage+ yields
280
- # +[wall_time, memory_peak]+ in +Kobako::Usage+ field order.
281
- def read_usage!
282
- wall_time, memory_peak = @runtime.usage
283
- @usage = Usage.new(wall_time: wall_time, memory_peak: memory_peak)
284
- end
285
-
286
- # Pick the +TrapError+ subclass to re-raise based on +err+'s actual
287
- # class. Cap-trap subclasses (+TimeoutError+ / +MemoryLimitError+)
288
- # preserve their named identity; everything else collapses to the
289
- # base +Kobako::TrapError+. The ext already raises the right subclass
290
- # directly, so this is a pure re-attribution that lets +#invoke!+
291
- # add the verb prefix without erasing +TimeoutError+ /
292
- # +MemoryLimitError+.
293
- def trap_class_for(err)
294
- case err
295
- when TimeoutError then TimeoutError
296
- when MemoryLimitError then MemoryLimitError
297
- else TrapError
298
- end
299
- end
300
-
301
- # Read the per-last-invocation output captures from the ext and wrap
302
- # them as +Kobako::Capture+ value objects. Runs in the +invoke!+
303
- # +ensure+ block next to #read_usage! for the same reason: the ext
304
- # stashes the captures on every outcome, so the readout also covers
305
- # the trap path, where +Runtime#eval+ / +#run+ raise instead of
306
- # returning outcome bytes — +#stdout+ / +#stderr+ keep the guest's
307
- # partial output readable after a rescue.
308
- #
309
- # The ext-side contract is positional: +Runtime#captures+ yields
310
- # +[stdout_bytes, stdout_truncated, stderr_bytes, stderr_truncated]+.
311
- def read_captures!
312
- stdout_bytes, stdout_truncated, stderr_bytes, stderr_truncated = @runtime.captures
313
- @stdout_capture = Capture.new(bytes: stdout_bytes, truncated: stdout_truncated)
314
- @stderr_capture = Capture.new(bytes: stderr_bytes, truncated: stderr_truncated)
315
- end
316
-
317
- # Shared prologue / epilogue + trap-class translator for both
318
- # invocation verbs. +verb+ is +:eval+ or +:run+; it tags the
319
- # TrapError message so the failing export is identifiable.
320
- #
321
- # The yielded block must return the invocation's raw outcome bytes —
322
- # i.e. the value of +Runtime#eval+ / +#run+ — which the success path
323
- # feeds to +Outcome.decode+. Captures and usage are populated by the
324
- # +ensure+ readouts (#read_usage! / #read_captures!) on every
325
- # outcome, so +#stdout+ / +#stderr+ / +#usage+ stay readable after a
326
- # rescued trap.
327
- # The rescue chain is the single trap-translation boundary —
328
- # configured-cap paths surface as named TrapError subclasses
329
- # (+TimeoutError+ / +MemoryLimitError+); everything else surfaces as
330
- # the base +TrapError+.
331
- def invoke!(verb)
332
- begin_invocation!
333
- return_bytes = yield
334
- # A Capability Handle in the result is decoded as a Kobako::Handle
335
- # token; restore it to the host object the guest referenced before
336
- # handing the value to the Host App. @handler still holds this
337
- # invocation's table — reset only happens at the next #begin_invocation!.
338
- # A Handle-free result resolves to itself, so the restoration walk is
339
- # skipped when the decode carried none.
340
- value, carried_handle = Codec.track_handles { Outcome.decode(return_bytes) }
341
- carried_handle ? Codec::HandleWalk.deep_restore(value, @handler) : value
342
- rescue Kobako::TrapError => e
343
- raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
344
- ensure
345
- read_usage!
346
- read_captures!
347
208
  end
348
209
  end
349
210
  end
@@ -21,7 +21,7 @@ module Kobako
21
21
  # as-is. The options also own the ladder comparison
22
22
  # (+#enforce_floor!+) that +Kobako::Sandbox+ delegates its
23
23
  # construction floor check to.
24
- class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile)
24
+ class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile, :gvl)
25
25
  # Default wall-clock timeout for a single invocation: 60 seconds.
26
26
  DEFAULT_TIMEOUT_SECONDS = 60.0
27
27
 
@@ -41,16 +41,28 @@ module Kobako
41
41
  # +:permissive+ is the Host App's explicit trade.
42
42
  DEFAULT_PROFILE = :hermetic
43
43
 
44
+ # The GVL scheduling modes: +:hold+ keeps Ruby's GVL for the whole
45
+ # invocation, +:release+ drops it for the guest span so distinct
46
+ # Sandboxes on distinct Threads run their guest code in parallel.
47
+ GVL_MODES = %i[hold release].freeze
48
+
49
+ # Default GVL mode: +:hold+ — holding the GVL matches single-threaded
50
+ # execution, so +:release+ is the Host App's explicit opt-in for
51
+ # host-parallel guest execution.
52
+ DEFAULT_GVL = :hold
53
+
44
54
  def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
45
55
  memory_limit: DEFAULT_MEMORY_LIMIT,
46
56
  stdout_limit: DEFAULT_OUTPUT_LIMIT,
47
57
  stderr_limit: DEFAULT_OUTPUT_LIMIT,
48
- profile: DEFAULT_PROFILE)
58
+ profile: DEFAULT_PROFILE,
59
+ gvl: DEFAULT_GVL)
49
60
  timeout = normalize_timeout(timeout)
50
61
  memory_limit = normalize_memory_limit(memory_limit)
51
62
  stdout_limit = normalize_output_limit(stdout_limit, "stdout_limit")
52
63
  stderr_limit = normalize_output_limit(stderr_limit, "stderr_limit")
53
64
  profile = normalize_profile(profile)
65
+ gvl = normalize_gvl(gvl)
54
66
  super
55
67
  end
56
68
 
@@ -120,5 +132,14 @@ module Kobako
120
132
 
121
133
  raise ArgumentError, "profile must be one of #{PROFILES.map(&:inspect).join(", ")}, got #{profile.inspect}"
122
134
  end
135
+
136
+ # Validate +gvl+ against GVL_MODES. Like +profile+ it has no +nil+
137
+ # form: the scheduling mode is requested as an explicit Symbol, so
138
+ # anything off the set — +nil+ included — is rejected.
139
+ def normalize_gvl(gvl)
140
+ return gvl if GVL_MODES.include?(gvl)
141
+
142
+ raise ArgumentError, "gvl must be one of #{GVL_MODES.map(&:inspect).join(", ")}, got #{gvl.inspect}"
143
+ end
123
144
  end
124
145
  end
@@ -13,7 +13,8 @@ module Kobako
13
13
  module Transport
14
14
  # Pure-function dispatcher for guest-initiated transport calls.
15
15
  # Decodes a msgpack-encoded Request envelope, resolves the target
16
- # object through the Catalog::Services (path lookup) or
16
+ # object through the per-invocation path +resolver+ (the +Context+, whose
17
+ # +#lookup+ layers per-invocation providers over the static bindings) or
17
18
  # Catalog::Handles (Handle lookup), invokes the method, and returns
18
19
  # a msgpack-encoded Response envelope.
19
20
  #
@@ -24,7 +25,7 @@ module Kobako
24
25
  #
25
26
  # Entry point:
26
27
  #
27
- # Kobako::Transport::Dispatcher.dispatch(request_bytes, services, handler, yield_to_guest)
28
+ # Kobako::Transport::Dispatcher.dispatch(request_bytes, resolver, handler, yield_to_guest)
28
29
  # # => msgpack-encoded Response bytes (never raises)
29
30
  module Dispatcher
30
31
  # Throw tag for the Yielder's break unwind back to the
@@ -66,12 +67,12 @@ module Kobako
66
67
  private_constant :CALLABLE_ALLOW
67
68
 
68
69
  # Dispatch a single transport request and return the encoded
69
- # Response bytes. Invoked from the +Runtime#on_dispatch+ Proc that
70
- # +Kobako::Sandbox#initialize+ installs on the ext side; +services+,
70
+ # Response bytes. Invoked from the per-invocation dispatch Proc that
71
+ # +Kobako::Context+ hands to +Runtime#eval+ / +#run+; +resolver+,
71
72
  # +handler+, and +yield_to_guest+ are captured in that Proc's
72
- # closure so the Dispatcher stays stateless and the registry doesn't
73
- # need to publish accessors for the Sandbox-owned +Catalog::Handles+
74
- # or +Runtime+. +yield_to_guest+ is a +String → String+ callable
73
+ # closure so the Dispatcher stays stateless and neither the resolver
74
+ # nor the Context needs to publish accessors for the per-invocation
75
+ # +Catalog::Handles+ or +Runtime+. +yield_to_guest+ is a +String → String+ callable
75
76
  # (the ext's per-dispatch +Kobako::Runtime::GuestYielder+) used only
76
77
  # when the Request carries +block_given: true+. Always
77
78
  # returns a binary String — every failure path is reified as a
@@ -81,9 +82,9 @@ module Kobako
81
82
  # The decode runs inside +Codec.track_handles+ so #resolve_call_args
82
83
  # can skip the argument walk when no Capability Handle crossed the
83
84
  # wire.
84
- def dispatch(request_bytes, services, handler, yield_to_guest)
85
+ def dispatch(request_bytes, resolver, handler, yield_to_guest)
85
86
  request, carried_handle = Kobako::Codec.track_handles { Kobako::Transport::Request.decode(request_bytes) }
86
- target = resolve_target(request.target, services, handler)
87
+ target = resolve_target(request.target, resolver, handler)
87
88
  args, kwargs = resolve_call_args(request, handler, carried_handle)
88
89
  yielder = Yielder.new(yield_to_guest, BREAK_THROW, handler) if request.block_given
89
90
  encode_ok(catch(BREAK_THROW) { invoke(target, request.method_name, args, kwargs, yielder) }, handler)
@@ -192,24 +193,24 @@ module Kobako
192
193
  raise UndefinedTargetError, e.message
193
194
  end
194
195
 
195
- # Resolve a Request target to the Ruby object the registry (or
196
- # Catalog::Handles) holds. String targets go through the registry;
196
+ # Resolve a Request target to the Ruby object the path +resolver+ (or
197
+ # Catalog::Handles) holds. String targets go through the resolver;
197
198
  # Handle targets (ext 0x01) go through the Catalog::Handles.
198
199
  #
199
200
  # Target type is already validated by +Transport::Request.decode+
200
201
  # before this method is reached, so no else-branch is needed here —
201
202
  # the wire layer is the system boundary that enforces the invariant.
202
- def resolve_target(target, services, handler)
203
+ def resolve_target(target, resolver, handler)
203
204
  case target
204
205
  when String
205
- resolve_path(target, services)
206
+ resolve_path(target, resolver)
206
207
  when Kobako::Handle
207
208
  require_live_object!(target.id, handler)
208
209
  end
209
210
  end
210
211
 
211
- def resolve_path(path, services)
212
- services.lookup(path)
212
+ def resolve_path(path, resolver)
213
+ resolver.lookup(path)
213
214
  rescue KeyError => e
214
215
  raise UndefinedTargetError, e.message
215
216
  end
@@ -47,7 +47,7 @@ module Kobako
47
47
  # #invalidate!. The ok value is consumed by the host Service
48
48
  # method, so a Capability Handle in it is restored to its host object.
49
49
  # The break value unwinds past the Service back to the guest
50
- # Member call, so it passes through verbatim — a Handle stays a
50
+ # bound-constant call, so it passes through verbatim — a Handle stays a
51
51
  # Handle and rides back on the same id rather than churning a new one.
52
52
  def yield(*args)
53
53
  raise LocalJumpError, "guest block invoked after host dispatch frame returned" unless @active
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ # Kobako::Unresolved — the sentinel backing a fillable Service path: one
5
+ # declared with +Sandbox#bind(path)+ carrying no object. It reserves the
6
+ # path's Frame 1 slot so the guest sees the bound constant while the host
7
+ # defers the object it stands for. A guest dispatch to an unfilled fillable
8
+ # is refused as an unresolved target and surfaces as +Kobako::ServiceError+
9
+ # when the guest leaves it unrescued — the same capability-failure channel
10
+ # as an idiom with no backend bound.
11
+ #
12
+ # A single shared value; only its identity distinguishes it, so the dispatch
13
+ # layer recognises it with +equal?+. A Host App may name it at a +bind+ site
14
+ # to declare a fillable explicitly.
15
+ module Unresolved
16
+ end
17
+ end
data/lib/kobako/usage.rb CHANGED
@@ -21,16 +21,16 @@ module Kobako
21
21
  # the high-water.
22
22
  #
23
23
  # Both readers are populated on every outcome, including +TrapError+
24
- # branches, so the Host App can read +Sandbox#usage+ after rescuing a
25
- # trap to diagnose how much of the budget the failing invocation
26
- # consumed. Before the first invocation +Sandbox#usage+ returns the
27
- # pre-invocation sentinel +Kobako::Usage::EMPTY+.
24
+ # branches, so the Host App can read +#usage+ off the run's
25
+ # +Kobako::Execution+ — the one a raised error carries on +#execution+ —
26
+ # after rescuing a trap to diagnose how much of the budget the failing
27
+ # invocation consumed.
28
28
  #
29
29
  # Built on the +class X < Data.define(...)+ subclass form (the
30
30
  # Steep-friendly shape — see +lib/kobako/outcome/panic.rb+).
31
31
  class Usage < Data.define(:wall_time, :memory_peak)
32
- # Pre-invocation sentinel. Reused by +Sandbox+ before any invocation
33
- # has run so callers do not need to handle a +nil+ +#usage+.
32
+ # Pre-run sentinel. A fresh +Kobako::Context+ holds it until its guest
33
+ # runs, so an Execution's +#usage+ is never +nil+.
34
34
  EMPTY = new(wall_time: 0.0, memory_peak: 0)
35
35
  end
36
36
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- VERSION = "0.18.0"
4
+ VERSION = "0.20.0"
5
5
  end
data/lib/kobako.rb CHANGED
@@ -10,6 +10,7 @@ rescue LoadError
10
10
  end
11
11
 
12
12
  require_relative "kobako/errors"
13
+ require_relative "kobako/unresolved"
13
14
  require_relative "kobako/extension"
14
15
  require_relative "kobako/transport"
15
16
  require_relative "kobako/catalog"
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
3
  "release-type": "ruby",
4
+ "bump-minor-pre-major": true,
4
5
  "last-release-sha": "98509af508988f708bf0d7a76a718bb0428a177e",
5
6
  "packages": {
6
7
  ".": {
7
8
  "component": "kobako",
8
9
  "include-component-in-tag": false,
9
10
  "release-type": "ruby",
10
- "exclude-paths": ["wasm"]
11
+ "exclude-paths": ["wasm", "crates"]
11
12
  },
12
13
  "wasm/kobako-core": {
13
14
  "component": "kobako-core",
@@ -7,21 +7,19 @@ module Kobako
7
7
 
8
8
  def seal!: () -> self
9
9
 
10
- def refresh_backends!: (Kobako::Catalog::Services services) -> self
10
+ def resolve: () -> Hash[String, untyped]
11
11
 
12
12
  private
13
13
 
14
- def refresh_backend: (untyped extension, Kobako::Catalog::Services services, Hash[untyped, untyped] resolved) -> void
14
+ def install_object: (untyped backend) -> untyped
15
+
16
+ def resolve_backend: (untyped extension, Hash[String, untyped] resolved, Hash[untyped, untyped] by_provider) -> void
15
17
 
16
18
  def validate!: (untyped extension) -> void
17
19
 
18
20
  def assert_dependencies!: () -> void
19
21
 
20
22
  def symbolize: (untyped name) -> untyped
21
-
22
- def callable?: (untyped provider) -> bool
23
-
24
- def initial_object: (untyped provider) -> untyped
25
23
  end
26
24
  end
27
25
  end
@@ -7,8 +7,6 @@ module Kobako
7
7
 
8
8
  def fetch: (Integer id) -> untyped
9
9
 
10
- def reset!: () -> self
11
-
12
10
  def size: () -> Integer
13
11
 
14
12
  private
@@ -3,13 +3,13 @@ module Kobako
3
3
  class Services
4
4
  NAME_PATTERN: Regexp
5
5
 
6
- def initialize: (?handler: Kobako::Catalog::Handles) -> void
6
+ def initialize: () -> void
7
7
 
8
8
  def bind: (Symbol | String path, untyped object) -> self
9
9
 
10
- def lookup: (Symbol | String target) -> untyped
10
+ def bound?: (Symbol | String path) -> bool
11
11
 
12
- def refresh: (Symbol | String path, untyped object) -> self
12
+ def lookup: (Symbol | String target) -> untyped
13
13
 
14
14
  def encode: () -> String
15
15
 
@@ -11,6 +11,8 @@ module Kobako
11
11
 
12
12
  def encode: () -> String
13
13
 
14
+ def seal!: () -> self
15
+
14
16
  private
15
17
 
16
18
  def register_source!: (String? code, (Symbol | String)? name) -> Symbol