kobako 0.4.0 → 0.6.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -0
  3. data/CHANGELOG.md +44 -0
  4. data/Cargo.lock +1 -1
  5. data/README.md +89 -205
  6. data/data/kobako.wasm +0 -0
  7. data/ext/kobako/Cargo.toml +1 -1
  8. data/ext/kobako/src/lib.rs +4 -2
  9. data/ext/kobako/src/{wasm → runtime}/cache.rs +12 -16
  10. data/ext/kobako/src/runtime/capture.rs +91 -0
  11. data/ext/kobako/src/runtime/config.rs +26 -0
  12. data/ext/kobako/src/runtime/dispatch.rs +211 -0
  13. data/ext/kobako/src/runtime/exports.rs +51 -0
  14. data/ext/kobako/src/runtime/guest_mem.rs +228 -0
  15. data/ext/kobako/src/{wasm/host_state.rs → runtime/invocation.rs} +94 -86
  16. data/ext/kobako/src/runtime/trap.rs +134 -0
  17. data/ext/kobako/src/runtime.rs +782 -0
  18. data/ext/kobako/src/snapshot.rs +110 -0
  19. data/lib/kobako/capture.rb +11 -16
  20. data/lib/kobako/catalog/handles.rb +107 -0
  21. data/lib/kobako/catalog/namespaces.rb +100 -0
  22. data/lib/kobako/{snippet/table.rb → catalog/snippets.rb} +37 -62
  23. data/lib/kobako/catalog.rb +18 -0
  24. data/lib/kobako/codec/decoder.rb +13 -5
  25. data/lib/kobako/codec/factory.rb +12 -12
  26. data/lib/kobako/codec/utils.rb +83 -59
  27. data/lib/kobako/codec.rb +6 -3
  28. data/lib/kobako/errors.rb +45 -28
  29. data/lib/kobako/fault.rb +40 -0
  30. data/lib/kobako/handle.rb +4 -6
  31. data/lib/kobako/namespace.rb +67 -0
  32. data/lib/kobako/outcome.rb +31 -35
  33. data/lib/kobako/runtime.rb +30 -0
  34. data/lib/kobako/sandbox.rb +88 -72
  35. data/lib/kobako/sandbox_options.rb +6 -9
  36. data/lib/kobako/snapshot.rb +40 -0
  37. data/lib/kobako/snippet/binary.rb +6 -7
  38. data/lib/kobako/snippet/source.rb +8 -8
  39. data/lib/kobako/snippet.rb +7 -9
  40. data/lib/kobako/transport/dispatcher.rb +195 -0
  41. data/lib/kobako/{rpc/wire_error.rb → transport/error.rb} +7 -6
  42. data/lib/kobako/transport/request.rb +79 -0
  43. data/lib/kobako/transport/response.rb +69 -0
  44. data/lib/kobako/transport/run.rb +141 -0
  45. data/lib/kobako/transport/yield.rb +91 -0
  46. data/lib/kobako/transport/yielder.rb +108 -0
  47. data/lib/kobako/transport.rb +24 -0
  48. data/lib/kobako/version.rb +1 -1
  49. data/lib/kobako.rb +4 -4
  50. data/release-please-config.json +24 -0
  51. data/sig/kobako/capture.rbs +0 -2
  52. data/sig/kobako/catalog/handles.rbs +19 -0
  53. data/sig/kobako/catalog/namespaces.rbs +17 -0
  54. data/sig/kobako/{snippet/table.rbs → catalog/snippets.rbs} +2 -11
  55. data/sig/kobako/{rpc.rbs → catalog.rbs} +1 -1
  56. data/sig/kobako/codec/decoder.rbs +2 -1
  57. data/sig/kobako/codec/factory.rbs +2 -2
  58. data/sig/kobako/codec/utils.rbs +7 -5
  59. data/sig/kobako/errors.rbs +7 -7
  60. data/sig/kobako/fault.rbs +19 -0
  61. data/sig/kobako/handle.rbs +2 -3
  62. data/sig/kobako/namespace.rbs +19 -0
  63. data/sig/kobako/outcome.rbs +2 -2
  64. data/sig/kobako/runtime.rbs +23 -0
  65. data/sig/kobako/sandbox.rbs +5 -8
  66. data/sig/kobako/snapshot.rbs +15 -0
  67. data/sig/kobako/transport/dispatcher.rbs +34 -0
  68. data/sig/kobako/transport/error.rbs +6 -0
  69. data/sig/kobako/transport/request.rbs +32 -0
  70. data/sig/kobako/transport/response.rbs +30 -0
  71. data/sig/kobako/transport/run.rbs +27 -0
  72. data/sig/kobako/transport/yield.rbs +34 -0
  73. data/sig/kobako/transport/yielder.rbs +24 -0
  74. data/sig/kobako/transport.rbs +4 -0
  75. metadata +48 -30
  76. data/ext/kobako/src/wasm/dispatch.rs +0 -162
  77. data/ext/kobako/src/wasm/instance.rs +0 -873
  78. data/ext/kobako/src/wasm.rs +0 -126
  79. data/lib/kobako/handle_table.rb +0 -119
  80. data/lib/kobako/invocation.rb +0 -143
  81. data/lib/kobako/rpc/dispatcher.rb +0 -171
  82. data/lib/kobako/rpc/envelope.rb +0 -118
  83. data/lib/kobako/rpc/fault.rb +0 -41
  84. data/lib/kobako/rpc/namespace.rb +0 -74
  85. data/lib/kobako/rpc/server.rb +0 -146
  86. data/lib/kobako/rpc.rb +0 -11
  87. data/lib/kobako/wasm.rb +0 -25
  88. data/sig/kobako/handle_table.rbs +0 -23
  89. data/sig/kobako/invocation.rbs +0 -25
  90. data/sig/kobako/rpc/dispatcher.rbs +0 -33
  91. data/sig/kobako/rpc/envelope.rbs +0 -51
  92. data/sig/kobako/rpc/fault.rbs +0 -20
  93. data/sig/kobako/rpc/namespace.rbs +0 -24
  94. data/sig/kobako/rpc/server.rbs +0 -31
  95. data/sig/kobako/rpc/wire_error.rbs +0 -6
  96. data/sig/kobako/wasm.rbs +0 -41
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ # Host-side wasmtime runtime, surfaced as a Ruby class by the native ext
5
+ # (see ext/kobako/src/runtime.rs). The +Kobako::Runtime+ class wraps the
6
+ # wasmtime engine + compiled module + Store; it is the only Ruby-visible
7
+ # wasmtime type and the foundational binding layer for +Kobako::Sandbox+.
8
+ #
9
+ # This file reopens the magnus-defined class only to add the pure-Ruby
10
+ # +.default_path+ helper. Every other method (+#from_path+ singleton,
11
+ # +#eval+ / +#run+, capture and usage readers) is registered from Rust
12
+ # at ext load time.
13
+ class Runtime
14
+ # Absolute path to the gem-bundled +data/kobako.wasm+ artifact. Computed
15
+ # from this file's location so it works for both +bundle exec+ (running
16
+ # from the repo) and an installed gem (running from the gem dir).
17
+ #
18
+ # Returns a String regardless of whether the file currently exists —
19
+ # call sites that need the file to be present should pass this through
20
+ # +Kobako::Runtime.from_path+, which raises
21
+ # +Kobako::ModuleNotBuiltError+ with a clear remediation message.
22
+ # Raises +Kobako::SetupError+ if +__dir__+ is unavailable so that
23
+ # +rescue Kobako::SetupError+ around +Kobako::Sandbox.new+ catches every
24
+ # construction-layer failure uniformly, including this path-resolution one.
25
+ def self.default_path
26
+ dir = __dir__ or raise Kobako::SetupError, "Kobako::Runtime.default_path requires __dir__"
27
+ File.expand_path("../../data/kobako.wasm", dir)
28
+ end
29
+ end
30
+ end
@@ -3,29 +3,29 @@
3
3
  require "forwardable"
4
4
 
5
5
  require_relative "capture"
6
+ require_relative "codec"
6
7
  require_relative "errors"
7
- require_relative "handle_table"
8
- require_relative "invocation"
9
8
  require_relative "outcome"
10
- require_relative "rpc/server"
11
- require_relative "rpc/envelope"
12
9
  require_relative "sandbox_options"
13
- require_relative "snippet"
14
10
  require_relative "usage"
11
+ require_relative "transport"
12
+ require_relative "catalog"
15
13
 
16
14
  module Kobako
17
15
  # Kobako::Sandbox — the user-facing entry point for executing guest mruby
18
16
  # scripts inside a wasmtime-hosted Wasm module
19
17
  # ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
20
18
  #
21
- # The Sandbox owns the +Kobako::Wasm::Instance+, the per-Sandbox
22
- # +Kobako::HandleTable+ ({docs/behavior.md B-19}[link:../../docs/behavior.md]),
23
- # the per-instance RPC Server (which receives the HandleTable by
24
- # injection so guest→host dispatch and host→guest auto-wrap share one
25
- # allocator), and the per-channel byte caches for guest stdout / stderr
26
- # capture. The underlying wasmtime Engine and compiled Module are cached
27
- # at process scope by the native ext and never surface to Ruby —
28
- # constructing many Sandboxes amortises both costs automatically.
19
+ # The Sandbox owns the +Kobako::Runtime+, the per-Sandbox
20
+ # +Kobako::Catalog::Handles+ ({docs/behavior.md B-19}[link:../../docs/behavior.md]),
21
+ # the per-instance +Kobako::Catalog::Namespaces+ (which receives the
22
+ # +Catalog::Handles+ by injection so guest→host dispatch and host→guest
23
+ # auto-wrap share one allocator), and the dispatch +Proc+ /
24
+ # +yield_to_guest+ lambda installed on the Runtime via
25
+ # +Runtime#on_dispatch=+ ({docs/behavior.md B-12}[link:../../docs/behavior.md]).
26
+ # The underlying wasmtime Engine and compiled Module are cached at process
27
+ # scope by the native ext and never surface to Ruby — constructing many
28
+ # Sandboxes amortises both costs automatically.
29
29
  #
30
30
  # Output capture policy ({docs/behavior.md B-04}[link:../../docs/behavior.md]): the
31
31
  # per-channel cap (+stdout_limit+ / +stderr_limit+) is enforced inside the
@@ -38,9 +38,7 @@ module Kobako
38
38
  class Sandbox
39
39
  extend Forwardable
40
40
 
41
- attr_reader :wasm_path, :instance,
42
- :options,
43
- :services, :snippets
41
+ attr_reader :wasm_path, :options
44
42
 
45
43
  # Per-cap accessors forward to the immutable +SandboxOptions+ Value
46
44
  # Object so the Host App still reads them off Sandbox directly.
@@ -99,21 +97,22 @@ module Kobako
99
97
  def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil,
100
98
  timeout: SandboxOptions::DEFAULT_TIMEOUT_SECONDS,
101
99
  memory_limit: SandboxOptions::DEFAULT_MEMORY_LIMIT)
102
- @wasm_path = wasm_path || Kobako::Wasm.default_path
100
+ @wasm_path = wasm_path || Kobako::Runtime.default_path
103
101
  @options = SandboxOptions.new(timeout: timeout, memory_limit: memory_limit, stdout_limit: stdout_limit,
104
102
  stderr_limit: stderr_limit)
105
- @handle_table = HandleTable.new
106
- @services = Kobako::RPC::Server.new(handle_table: @handle_table)
107
- @snippets = Snippet::Table.new
108
- @instance = Kobako::Wasm::Instance.from_path(@wasm_path, @options.timeout, @options.memory_limit,
109
- @options.stdout_limit, @options.stderr_limit)
110
- @instance.server = @services
103
+ @handler = Catalog::Handles.new
104
+ @services = Kobako::Catalog::Namespaces.new(handler: @handler)
105
+ @snippets = Catalog::Snippets.new
106
+ @runtime = Kobako::Runtime.from_path(@wasm_path, @options.timeout, @options.memory_limit,
107
+ @options.stdout_limit, @options.stderr_limit)
108
+ install_dispatch_proc!
111
109
  reset_invocation_state!
112
110
  end
113
111
 
114
112
  # Declare or retrieve the Namespace named +name+ on this Sandbox
115
113
  # ({docs/behavior.md B-07, B-09, B-10}[link:../../docs/behavior.md]). +name+ must be a
116
- # Symbol or String in constant form. Returns the +Kobako::RPC::Namespace+.
114
+ # Symbol or String in constant form. Returns the
115
+ # +Kobako::Namespace+.
117
116
  #
118
117
  # Raises +ArgumentError+ when called after the first invocation, or
119
118
  # when +name+ does not match the constant-name pattern.
@@ -160,17 +159,19 @@ module Kobako
160
159
 
161
160
  # Dispatch into a preloaded entrypoint constant
162
161
  # ({docs/behavior.md B-31}[link:../../docs/behavior.md]). Delegates host
163
- # pre-flight (E-24 / E-25 / E-29 / E-30) and wire encoding to
164
- # +Kobako::Invocation+ / +Kobako::Invocation#encode+; the guest
165
- # resolves +target+ as a top-level constant, calls +#call+ on it
166
- # with +args+ / +kwargs+, and returns the deserialized result. The
167
- # first invocation seals the Service registry and snippet table
168
- # (B-07 / B-33). Runtime errors follow the same three-class taxonomy
169
- # as +#eval+.
162
+ # pre-flight and wire encoding to +Kobako::Transport::Run+ /
163
+ # +Kobako::Transport::Run#encode+: a non-Symbol/String +target+ raises
164
+ # +TypeError+ (E-24), while a +target+ failing the constant pattern
165
+ # (E-25), a forged +Kobako::Handle+ in +args+ / +kwargs+ (E-29), or a
166
+ # non-Symbol +kwargs+ key (E-30) raise +ArgumentError+. The guest
167
+ # resolves +target+ as a top-level constant, calls +#call+ on it with
168
+ # +args+ / +kwargs+, and returns the deserialized result. The first
169
+ # invocation seals the Service registry and snippet table (B-07 /
170
+ # B-33). Runtime errors follow the same three-class taxonomy as +#eval+.
170
171
  def run(target, *args, **kwargs)
171
- invocation = Invocation.new(entrypoint: target, args: args, kwargs: kwargs)
172
+ run_envelope = Transport::Run.new(entrypoint: target, args: args, kwargs: kwargs)
172
173
  invoke!(:run) do
173
- @instance.run(@services.encoded_preamble, @snippets.encode, invocation.encode(@handle_table))
174
+ @runtime.run(@services.encode, @snippets.encode, run_envelope.encode(@handler))
174
175
  end
175
176
  end
176
177
 
@@ -201,23 +202,38 @@ module Kobako
201
202
  raise SandboxError, "code must be a String, got #{code.class}" unless code.is_a?(String)
202
203
 
203
204
  invoke!(:eval) do
204
- @instance.eval(@services.encoded_preamble, code.b, @snippets.encode)
205
+ @runtime.eval(@services.encode, code.b, @snippets.encode)
205
206
  end
206
207
  end
207
208
 
208
209
  private
209
210
 
211
+ # Configure the +Runtime+'s host↔guest dispatch wiring
212
+ # ({docs/behavior.md B-12}[link:../../docs/behavior.md]). Builds a
213
+ # lambda that re-enters the guest via
214
+ # +Runtime#yield_to_active_invocation+ (B-24) and a dispatch +Proc+
215
+ # that routes guest→host calls through the stateless
216
+ # +Transport::Dispatcher+, capturing +@services+ / +@handler+ in the
217
+ # closure. Both are registered on the +Runtime+ once at construction
218
+ # time so the wasm ext callback can fire without further setup.
219
+ def install_dispatch_proc!
220
+ yield_to_guest = ->(args_bytes) { @runtime.yield_to_active_invocation(args_bytes) }
221
+ @runtime.on_dispatch = lambda do |request_bytes|
222
+ Transport::Dispatcher.dispatch(request_bytes, @services, @handler, yield_to_guest)
223
+ end
224
+ end
225
+
210
226
  # Per-invocation prologue ({docs/behavior.md B-03 / B-07 /
211
227
  # B-33}[link:../../docs/behavior.md]). Seals the Service / snippet
212
228
  # registries on first call (idempotent) and zeros the per-invocation
213
229
  # capability state — capture buffers, truncation predicates, and the
214
- # HandleTable counter — before the guest runs. The HandleTable
215
- # itself is held as +@handle_table+ and never exposed beyond
230
+ # +Catalog::Handles+ counter — before the guest runs. The
231
+ # +Catalog::Handles+ itself is held as +@handler+ and never exposed beyond
216
232
  # this class: SPEC.md Terminology pins it as "Not exposed to the
217
233
  # Host App" (B-19 / B-20 / E-29).
218
234
  def begin_invocation!
219
235
  @services.seal!
220
- @handle_table.reset!
236
+ @handler.reset!
221
237
  reset_invocation_state!
222
238
  end
223
239
 
@@ -235,66 +251,66 @@ module Kobako
235
251
  @usage = Usage::EMPTY
236
252
  end
237
253
 
238
- # Read the per-channel capture pairs (+[bytes, truncated]+) from the
239
- # ext after an invocation completes and wrap each as a +Capture+ value
240
- # object. The ext clips +bytes+ to the configured cap and sets
241
- # +truncated+ when the guest produced strictly more than +cap+ bytes
242
- # ({docs/behavior.md B-04}[link:../../docs/behavior.md]). Mirror of
243
- # {#reset_invocation_state!} at the post-invocation boundary.
244
- def read_captures!
245
- out_bytes, out_truncated = @instance.stdout
246
- err_bytes, err_truncated = @instance.stderr
247
- @stdout_capture = Capture.from_ext(out_bytes, out_truncated)
248
- @stderr_capture = Capture.from_ext(err_bytes, err_truncated)
249
- end
250
-
251
254
  # Read the per-last-invocation +wall_time+ and +memory_peak+ from
252
255
  # the ext and wrap them as a +Kobako::Usage+ value object
253
256
  # ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Runs in
254
257
  # the +invoke!+ +ensure+ block so the usage record is populated on
255
258
  # every outcome — value return, +Kobako::TrapError+ (including
256
259
  # +TimeoutError+ / +MemoryLimitError+), +Kobako::SandboxError+,
257
- # and +Kobako::ServiceError+.
260
+ # and +Kobako::ServiceError+. On the success path the same figures
261
+ # already arrive via +Snapshot#usage+; on the trap path the Snapshot
262
+ # never reaches Ruby so the ext readout here is the only source.
258
263
  #
259
264
  # The ext returns a positional 2-tuple +[wall_time, memory_peak]+
260
265
  # whose order matches the +Kobako::Usage+ field order; the
261
266
  # destructure-then-kwargs handoff below is the explicit
262
- # positional→keyword conversion point, mirroring
263
- # +#read_captures!+'s +Capture.from_ext(bytes, truncated)+ shape.
267
+ # positional→keyword conversion point.
264
268
  def read_usage!
265
- wall_time, memory_peak = @instance.usage
269
+ wall_time, memory_peak = @runtime.usage
266
270
  @usage = Usage.new(wall_time: wall_time, memory_peak: memory_peak)
267
271
  end
268
272
 
269
- # Map a wasmtime trap class to the matching three-layer Ruby
270
- # exception class. Cap-trap subclasses
273
+ # Pick the +TrapError+ subclass to re-raise based on +err+'s actual
274
+ # class. Cap-trap subclasses
271
275
  # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md])
272
- # select their named +TrapError+ subclass; everything else
273
- # collapses to the base +Kobako::TrapError+.
276
+ # preserve their named identity; everything else collapses to the
277
+ # base +Kobako::TrapError+. The ext already raises the right subclass
278
+ # directly, so this is a pure re-attribution that lets +#invoke!+
279
+ # add the verb prefix without erasing +TimeoutError+ /
280
+ # +MemoryLimitError+.
274
281
  def trap_class_for(err)
275
282
  case err
276
- when Kobako::Wasm::TimeoutError then TimeoutError
277
- when Kobako::Wasm::MemoryLimitError then MemoryLimitError
283
+ when TimeoutError then TimeoutError
284
+ when MemoryLimitError then MemoryLimitError
278
285
  else TrapError
279
286
  end
280
287
  end
281
288
 
282
289
  # Shared prologue / epilogue + trap-class translator for both
283
290
  # invocation verbs. +verb+ is +:eval+ or +:run+; it tags the
284
- # TrapError message so the failing export is identifiable. The
285
- # rescue chain is the single trap-translation boundary — wasmtime /
286
- # wire failures from the guest call and from the subsequent
287
- # +Instance#outcome!+ read both flow through here, so an
288
- # OUTCOME_BUFFER read failure attributes to the same export name as
289
- # the guest call itself. Configured-cap paths
290
- # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md]) surface as
291
- # named TrapError subclasses.
291
+ # TrapError message so the failing export is identifiable.
292
+ #
293
+ # The yielded block must return a +Kobako::Snapshot+ i.e. the
294
+ # value of +Runtime#eval+ / +#run+ (SPEC.md Internal Concepts
295
+ # Snapshot). The success path unpacks every observable from the
296
+ # Snapshot in one go: +#stdout+ / +#stderr+ pack into +Capture+,
297
+ # +#usage+ packs into +Usage+, +#return_bytes+ feeds +Outcome.decode+.
298
+ # The rescue chain is the single trap-translation boundary —
299
+ # configured-cap paths
300
+ # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md])
301
+ # surface as named TrapError subclasses; everything else surfaces as
302
+ # the base +TrapError+.
292
303
  def invoke!(verb)
293
304
  begin_invocation!
294
- yield
295
- read_captures!
296
- Outcome.decode(@instance.outcome!)
297
- rescue Kobako::Wasm::Error => e
305
+ snapshot = yield
306
+ @stdout_capture = snapshot.stdout
307
+ @stderr_capture = snapshot.stderr
308
+ # A Capability Handle in the result is decoded as a Kobako::Handle
309
+ # token; restore it to the host object the guest referenced before
310
+ # handing the value to the Host App (B-37). @handler still holds this
311
+ # invocation's table — reset only happens at the next #begin_invocation!.
312
+ Codec::Utils.deep_restore(Outcome.decode(snapshot.return_bytes), @handler)
313
+ rescue Kobako::TrapError => e
298
314
  raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
299
315
  ensure
300
316
  read_usage!
@@ -11,7 +11,7 @@ module Kobako
11
11
  # for absent values and normalises (timeout to Float seconds,
12
12
  # memory_limit to positive Integer bytes) before delegating to Data's
13
13
  # +super+. Anything that survives +SandboxOptions.new+ is a wire-ready
14
- # cap bundle the +Kobako::Wasm::Instance+ constructor consumes as-is.
14
+ # cap bundle the +Kobako::Runtime+ constructor consumes as-is.
15
15
  class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit)
16
16
  # Default wall-clock timeout for a single invocation: 60 seconds
17
17
  # ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
@@ -27,17 +27,15 @@ module Kobako
27
27
  # ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
28
28
  DEFAULT_OUTPUT_LIMIT = 1 << 20
29
29
 
30
- # steep:ignore:start
31
30
  def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
32
31
  memory_limit: DEFAULT_MEMORY_LIMIT,
33
32
  stdout_limit: nil,
34
33
  stderr_limit: nil)
35
- super(
36
- timeout: normalize_timeout(timeout),
37
- memory_limit: normalize_memory_limit(memory_limit),
38
- stdout_limit: stdout_limit || DEFAULT_OUTPUT_LIMIT,
39
- stderr_limit: stderr_limit || DEFAULT_OUTPUT_LIMIT
40
- )
34
+ timeout = normalize_timeout(timeout)
35
+ memory_limit = normalize_memory_limit(memory_limit)
36
+ stdout_limit ||= DEFAULT_OUTPUT_LIMIT
37
+ stderr_limit ||= DEFAULT_OUTPUT_LIMIT
38
+ super
41
39
  end
42
40
 
43
41
  private
@@ -68,6 +66,5 @@ module Kobako
68
66
 
69
67
  memory_limit
70
68
  end
71
- # steep:ignore:end
72
69
  end
73
70
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "capture"
4
+ require_relative "usage"
5
+
6
+ module Kobako
7
+ # Kobako::Snapshot — per-invocation observable bundle returned from
8
+ # +Kobako::Runtime#eval+ and +#run+.
9
+ #
10
+ # The magnus class (see ext/kobako/src/snapshot.rs) carries seven raw
11
+ # readers: +return_bytes+, +stdout_bytes+, +stdout_truncated+,
12
+ # +stderr_bytes+, +stderr_truncated+, +wall_time+, +memory_peak+. This
13
+ # file reopens the class to add the Ruby-side helpers that pack those
14
+ # raw fields into the user-facing value objects +Kobako::Capture+ and
15
+ # +Kobako::Usage+ — the same shape +Kobako::Sandbox+ exposes to the
16
+ # Host App.
17
+ class Snapshot
18
+ # Wrap the stdout capture pair (+stdout_bytes+, +stdout_truncated+)
19
+ # as a +Kobako::Capture+ value object. {docs/behavior.md
20
+ # B-04}[link:../../docs/behavior.md] — the byte content never carries
21
+ # a truncation sentinel; +#truncated?+ is the only way to observe
22
+ # that the cap was hit.
23
+ def stdout
24
+ Capture.new(bytes: stdout_bytes, truncated: stdout_truncated)
25
+ end
26
+
27
+ # Wrap the stderr capture pair as a +Kobako::Capture+ value object.
28
+ # Mirror of +#stdout+.
29
+ def stderr
30
+ Capture.new(bytes: stderr_bytes, truncated: stderr_truncated)
31
+ end
32
+
33
+ # Wrap the per-last-invocation usage pair (+wall_time+,
34
+ # +memory_peak+) as a +Kobako::Usage+ value object
35
+ # ({docs/behavior.md B-35}[link:../../docs/behavior.md]).
36
+ def usage
37
+ Usage.new(wall_time: wall_time, memory_peak: memory_peak)
38
+ end
39
+ end
40
+ end
@@ -3,23 +3,22 @@
3
3
  module Kobako
4
4
  module Snippet
5
5
  # Kobako::Snippet::Binary — value object representing a single
6
- # +#preload(binary:)+ entry held by +Kobako::Snippet::Table+
6
+ # +#preload(binary:)+ entry held by +Kobako::Catalog::Snippets+
7
7
  # ({docs/behavior.md B-32}[link:../../../docs/behavior.md]).
8
8
  #
9
9
  # The +body+ is RITE bytecode (as emitted by +mrbc+) carried as an
10
10
  # +ASCII_8BIT+ String so msgpack-ruby encodes it as +bin+ family on
11
11
  # the wire ({docs/wire-codec.md Invocation channels}[link:../../../docs/wire-codec.md]).
12
12
  # The host treats the bytes as opaque — the snippet's canonical
13
- # name, when present, lives in the bytecode's embedded
14
- # +debug_info+ and is resolved by the guest at load time;
15
- # structural validation
13
+ # name, when present, lives in the bytecode's embedded +debug_info+
14
+ # and is resolved by the guest at load time; structural validation
16
15
  # ({docs/behavior.md E-37 / E-38}[link:../../../docs/behavior.md])
17
16
  # is deferred to the first invocation's guest replay.
18
17
  #
19
18
  # The class is a +Data.define+ subclass — frozen and value-equal.
20
- # Callers (chiefly +Table+) construct instances via keyword form
21
- # +Binary.new(body: ...)+. Wire-form construction is the +Table+'s
22
- # responsibility.
19
+ # Callers (chiefly +Catalog::Snippets+) construct instances via keyword
20
+ # form +Binary.new(body: ...)+. Wire-form construction is the
21
+ # registry's responsibility.
23
22
  class Binary < Data.define(:body)
24
23
  # The +kind+ field value carried by bytecode snippets in their
25
24
  # Frame 3 wire envelope entry
@@ -3,21 +3,21 @@
3
3
  module Kobako
4
4
  module Snippet
5
5
  # Kobako::Snippet::Source — value object representing a single
6
- # +#preload(code:, name:)+ entry held by +Kobako::Snippet::Table+
6
+ # +#preload(code:, name:)+ entry held by +Kobako::Catalog::Snippets+
7
7
  # ({docs/behavior.md B-32}[link:../../../docs/behavior.md]).
8
8
  #
9
9
  # +name+ is the canonical +Symbol+ identity baked into the loaded
10
10
  # IREP's +debug_info+; backtrace frames originating in this snippet
11
11
  # surface as +(snippet:Name):line+. +body+ is the UTF-8 mruby source
12
- # detached from the caller's reference at +Table#register+ time so
13
- # later mutation of the original String cannot bleed through.
12
+ # detached from the caller's reference at +Catalog::Snippets#register+
13
+ # time so later mutation of the original String cannot bleed through.
14
14
  #
15
15
  # The class is a +Data.define+ subclass — frozen, value-equal, and
16
- # carries no mutation API. Callers (chiefly +Table+) construct
17
- # instances via keyword form +Source.new(name: ..., body: ...)+.
18
- # Wire-form construction is the +Table+'s responsibility, mirroring
19
- # +Kobako::RPC.encode_request+'s pattern of reading attributes off a
20
- # carrier rather than asking the carrier to self-describe.
16
+ # carries no mutation API. Callers (chiefly +Catalog::Snippets+)
17
+ # construct instances via keyword form +Source.new(name: ..., body: ...)+.
18
+ # Wire-form construction is the registry's responsibility: as a leaf
19
+ # carrier this Source stays pure and +Catalog::Snippets#encode+ reads
20
+ # its attributes off the outside rather than asking it to self-encode.
21
21
  class Source < Data.define(:name, :body)
22
22
  # The +kind+ field value carried by source snippets in their Frame
23
23
  # 3 wire envelope entry
@@ -2,19 +2,17 @@
2
2
 
3
3
  require_relative "snippet/binary"
4
4
  require_relative "snippet/source"
5
- require_relative "snippet/table"
6
5
 
7
6
  module Kobako
8
- # Kobako::Snippet — namespace for the per-Sandbox preloaded snippet
9
- # registry and its entry value objects
7
+ # Kobako::Snippet — value-object family for preloaded snippet entries
8
+ # held by +Kobako::Catalog::Snippets+
10
9
  # ({docs/behavior.md B-32 / B-33}[link:../../docs/behavior.md]).
11
10
  #
12
- # The +Table+ owns insertion-ordered storage and seal-coordination with
13
- # the owning Sandbox; +Source+ is the value object representing a single
14
- # +#preload(code:, name:)+ entry. Entry types live as siblings under
15
- # this module rather than nested under +Table+ so they remain plain
16
- # value objects with no implicit dependency on the registry that holds
17
- # them.
11
+ # +Source+ represents a single +#preload(code:, name:)+ entry; +Binary+
12
+ # represents a single +#preload(binary:)+ entry. Both are plain value
13
+ # objects with no dependency on the +Catalog::Snippets+ registry that
14
+ # holds them the registry reads their attributes externally when
15
+ # encoding the wire envelope.
18
16
  module Snippet
19
17
  end
20
18
  end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../codec"
4
+ require_relative "request"
5
+ require_relative "response"
6
+ require_relative "yield"
7
+ require_relative "yielder"
8
+
9
+ module Kobako
10
+ # See lib/kobako/transport.rb for the umbrella module doc; this file
11
+ # owns the pure-function dispatcher that decodes guest-initiated
12
+ # Requests and produces encoded Responses.
13
+ module Transport
14
+ # Pure-function dispatcher for guest-initiated transport calls.
15
+ # Decodes a msgpack-encoded Request envelope, resolves the target
16
+ # object through the Catalog::Namespaces (path lookup) or
17
+ # Catalog::Handles (Handle lookup), invokes the method, and returns
18
+ # a msgpack-encoded Response envelope.
19
+ #
20
+ # The module is stateless — all mutable state is threaded through
21
+ # arguments so Dispatcher has no instance variables and no side
22
+ # effects beyond mutating the Catalog::Handles via +alloc+ when a
23
+ # non-wire-representable return value must be wrapped
24
+ # ({docs/behavior.md B-14}[link:../../../docs/behavior.md]).
25
+ #
26
+ # Entry point:
27
+ #
28
+ # Kobako::Transport::Dispatcher.dispatch(request_bytes, namespaces, handler, yield_to_guest)
29
+ # # => msgpack-encoded Response bytes (never raises)
30
+ module Dispatcher
31
+ # Throw tag for the {Yielder}'s break unwind back to the
32
+ # dispatcher's +catch+ frame (B-25). +private_constant+ is a
33
+ # convention boundary — not a defence.
34
+ BREAK_THROW = :__kobako_break__
35
+ private_constant :BREAK_THROW
36
+
37
+ module_function
38
+
39
+ # Internal sentinel raised when target resolution fails. Mapped to
40
+ # Response.error with type="undefined". Contained at the wire boundary —
41
+ # not part of the public Kobako error taxonomy
42
+ # ({docs/behavior.md E-12}[link:../../../docs/behavior.md]).
43
+ class UndefinedTargetError < StandardError; end
44
+
45
+ # Dispatch a single transport request and return the encoded
46
+ # Response bytes ({docs/behavior.md B-12}[link:../../../docs/behavior.md]).
47
+ # Invoked from the +Runtime#on_dispatch+ Proc that
48
+ # +Kobako::Sandbox#initialize+ installs on the ext side; +namespaces+,
49
+ # +handler+, and +yield_to_guest+ are captured in that Proc's
50
+ # closure so the Dispatcher stays stateless and the registry doesn't
51
+ # need to publish accessors for the Sandbox-owned +Catalog::Handles+
52
+ # or +Runtime+. +yield_to_guest+ is a +String → String+ callable
53
+ # (typically +Runtime#yield_to_active_invocation+ bound as a lambda)
54
+ # used only when the Request carries +block_given: true+. Always
55
+ # returns a binary String — every failure path is reified as a
56
+ # Response.error envelope so the guest sees a transport error rather
57
+ # than a wasm trap.
58
+ def dispatch(request_bytes, namespaces, handler, yield_to_guest)
59
+ request = Kobako::Transport::Request.decode(request_bytes)
60
+ target = resolve_target(request.target, namespaces, handler)
61
+ args, kwargs = resolve_call_args(request, handler)
62
+ yielder = Yielder.new(yield_to_guest, BREAK_THROW, handler) if request.block_given
63
+ value = catch(BREAK_THROW) { invoke(target, request.method_name, args, kwargs, yielder) }
64
+ encode_ok(value, handler)
65
+ rescue StandardError => e
66
+ encode_caught_error(e)
67
+ ensure
68
+ yielder&.invalidate!
69
+ end
70
+
71
+ # Resolve positional and keyword arguments off +request+ in one
72
+ # step. Both pass through {#resolve_arg} so Capability Handles
73
+ # round-trip back to the host-side Ruby object before the call
74
+ # reaches +public_send+.
75
+ def resolve_call_args(request, handler)
76
+ args = request.args.map { |v| resolve_arg(v, handler) }
77
+ kwargs = request.kwargs.transform_values { |v| resolve_arg(v, handler) }
78
+ [args, kwargs]
79
+ end
80
+
81
+ # Map an error caught at the dispatch boundary to a +Response.error+
82
+ # envelope. +error+ is the +StandardError+ caught by {#dispatch}'s
83
+ # rescue. Returns a msgpack-encoded Response envelope (binary). Three
84
+ # error buckets ({docs/behavior.md B-12}[link:../../../docs/behavior.md]):
85
+ # +Kobako::Codec::Error+ → type="runtime" (malformed request);
86
+ # +UndefinedTargetError+ → type="undefined" (E-13); +ArgumentError+ →
87
+ # type="argument" (B-12 arity mismatch); everything else →
88
+ # type="runtime".
89
+ def encode_caught_error(error)
90
+ case error
91
+ when Kobako::Codec::Error then encode_error("runtime",
92
+ "Sandbox received a malformed request: #{error.message}")
93
+ when UndefinedTargetError then encode_error("undefined", error.message)
94
+ when ArgumentError then encode_error("argument", error.message)
95
+ else encode_error("runtime", "#{error.class}: #{error.message}")
96
+ end
97
+ end
98
+
99
+ # Dispatch +method+ on +target+. +kwargs+ is already Symbol-keyed
100
+ # (the +Request+ invariant pins it). The empty-kwargs branch omits
101
+ # the +**+ splat so Ruby 3.x's strict kwargs separation does not
102
+ # reject calls to no-kwarg methods when the wire carries the
103
+ # uniform empty-map shape.
104
+ #
105
+ # +yielder+ is the host-side {Yielder} materialised when the guest
106
+ # call site supplied a block ({docs/behavior.md
107
+ # B-23}[link:../../../docs/behavior.md]); its {Yielder#to_proc}
108
+ # rides the +&block+ slot. +&nil+ is a no-op block argument in Ruby,
109
+ # so the same call site handles both cases without an explicit
110
+ # conditional.
111
+ def invoke(target, method, args, kwargs, yielder = nil)
112
+ block = yielder&.to_proc
113
+ if kwargs.empty?
114
+ target.public_send(method.to_sym, *args, &block)
115
+ else
116
+ target.public_send(method.to_sym, *args, **kwargs, &block)
117
+ end
118
+ end
119
+
120
+ # {docs/behavior.md B-16}[link:../../../docs/behavior.md] — An Kobako::Handle arriving as a positional or keyword
121
+ # argument identifies a host-side object previously allocated by a prior
122
+ # transport call's Handle wrap (B-14). Resolve it back to the Ruby object before
123
+ # the dispatch reaches +public_send+.
124
+ def resolve_arg(value, handler)
125
+ case value
126
+ when Kobako::Handle
127
+ require_live_object!(value.id, handler)
128
+ else
129
+ value
130
+ end
131
+ end
132
+
133
+ # Resolve a Request target to the Ruby object the registry (or
134
+ # Catalog::Handles) holds. String targets go through the registry;
135
+ # Handle targets (ext 0x01) go through the Catalog::Handles.
136
+ #
137
+ # Target type is already validated by +Transport::Request.decode+
138
+ # before this method is reached, so no else-branch is needed here —
139
+ # the wire layer is the system boundary that enforces the invariant.
140
+ def resolve_target(target, namespaces, handler)
141
+ case target
142
+ when String
143
+ resolve_path(target, namespaces)
144
+ when Kobako::Handle
145
+ resolve_handle(target, handler)
146
+ end
147
+ end
148
+
149
+ def resolve_path(path, namespaces)
150
+ namespaces.lookup(path)
151
+ rescue KeyError => e
152
+ raise UndefinedTargetError, e.message
153
+ end
154
+
155
+ def resolve_handle(handle, handler)
156
+ require_live_object!(handle.id, handler)
157
+ end
158
+
159
+ # Resolve +id+ through the Catalog::Handles. An unknown id (E-13)
160
+ # surfaces as UndefinedTargetError.
161
+ def require_live_object!(id, handler)
162
+ handler.fetch(id)
163
+ rescue Kobako::SandboxError => e
164
+ raise UndefinedTargetError, e.message
165
+ end
166
+
167
+ # Encode +value+ as a +Response.ok+ envelope. When the value is not
168
+ # wire-representable per {docs/behavior.md B-13}[link:../../../docs/behavior.md]'s type
169
+ # mapping, the +UnsupportedType+ rescue routes it through the
170
+ # Catalog::Handles via {#wrap_as_handle} and re-encodes with the Capability
171
+ # Handle in place ({docs/behavior.md B-14}[link:../../../docs/behavior.md]). The happy
172
+ # path encodes exactly once.
173
+ def encode_ok(value, handler)
174
+ response = Kobako::Transport::Response.ok(value)
175
+ response.encode
176
+ rescue Kobako::Codec::UnsupportedType
177
+ encode_ok(wrap_as_handle(value, handler), handler)
178
+ end
179
+
180
+ # Allocate +value+ in the Sandbox's Catalog::Handles and return a +Handle+
181
+ # that the wire codec can carry ({docs/behavior.md B-14}[link:../../../docs/behavior.md]).
182
+ # Used as the fallback path of {#encode_ok} when +value+ has no wire
183
+ # representation.
184
+ def wrap_as_handle(value, handler)
185
+ handler.alloc(value)
186
+ end
187
+
188
+ def encode_error(type, message)
189
+ fault = Kobako::Fault.new(type: type, message: message)
190
+ response = Kobako::Transport::Response.error(fault)
191
+ response.encode
192
+ end
193
+ end
194
+ end
195
+ end