kobako 0.3.0 → 0.5.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -0
  3. data/CHANGELOG.md +29 -0
  4. data/Cargo.lock +1 -1
  5. data/README.md +85 -6
  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 +22 -18
  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} +195 -81
  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 +99 -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 -7
  25. data/lib/kobako/codec/factory.rb +21 -18
  26. data/lib/kobako/codec/utils.rb +118 -29
  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 +60 -0
  31. data/lib/kobako/namespace.rb +67 -0
  32. data/lib/kobako/outcome.rb +55 -29
  33. data/lib/kobako/runtime.rb +30 -0
  34. data/lib/kobako/sandbox.rb +131 -67
  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/transport/error.rb +24 -0
  42. data/lib/kobako/transport/request.rb +78 -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 +89 -0
  47. data/lib/kobako/transport.rb +24 -0
  48. data/lib/kobako/usage.rb +41 -0
  49. data/lib/kobako/version.rb +1 -1
  50. data/lib/kobako.rb +4 -3
  51. data/release-please-config.json +24 -0
  52. data/sig/kobako/capture.rbs +0 -2
  53. data/sig/kobako/{rpc/handle_table.rbs → catalog/handles.rbs} +3 -9
  54. data/sig/kobako/catalog/namespaces.rbs +17 -0
  55. data/sig/kobako/{snippet/table.rbs → catalog/snippets.rbs} +2 -11
  56. data/sig/kobako/{rpc.rbs → catalog.rbs} +1 -1
  57. data/sig/kobako/codec/decoder.rbs +2 -1
  58. data/sig/kobako/codec/factory.rbs +3 -3
  59. data/sig/kobako/codec/utils.rbs +11 -1
  60. data/sig/kobako/errors.rbs +7 -7
  61. data/sig/kobako/fault.rbs +19 -0
  62. data/sig/kobako/handle.rbs +18 -0
  63. data/sig/kobako/namespace.rbs +19 -0
  64. data/sig/kobako/outcome.rbs +2 -2
  65. data/sig/kobako/runtime.rbs +23 -0
  66. data/sig/kobako/sandbox.rbs +10 -7
  67. data/sig/kobako/snapshot.rbs +15 -0
  68. data/sig/kobako/transport/dispatcher.rbs +34 -0
  69. data/sig/kobako/transport/error.rbs +6 -0
  70. data/sig/kobako/transport/request.rbs +32 -0
  71. data/sig/kobako/transport/response.rbs +30 -0
  72. data/sig/kobako/transport/run.rbs +27 -0
  73. data/sig/kobako/transport/yield.rbs +34 -0
  74. data/sig/kobako/transport/yielder.rbs +21 -0
  75. data/sig/kobako/transport.rbs +4 -0
  76. data/sig/kobako/usage.rbs +11 -0
  77. metadata +52 -30
  78. data/ext/kobako/src/wasm/dispatch.rs +0 -161
  79. data/ext/kobako/src/wasm/instance.rs +0 -771
  80. data/ext/kobako/src/wasm.rs +0 -125
  81. data/lib/kobako/invocation.rb +0 -112
  82. data/lib/kobako/rpc/dispatcher.rb +0 -169
  83. data/lib/kobako/rpc/envelope.rb +0 -118
  84. data/lib/kobako/rpc/fault.rb +0 -41
  85. data/lib/kobako/rpc/handle.rb +0 -39
  86. data/lib/kobako/rpc/handle_table.rb +0 -107
  87. data/lib/kobako/rpc/namespace.rb +0 -74
  88. data/lib/kobako/rpc/server.rb +0 -158
  89. data/lib/kobako/rpc.rb +0 -11
  90. data/lib/kobako/wasm.rb +0 -25
  91. data/sig/kobako/invocation.rbs +0 -23
  92. data/sig/kobako/rpc/dispatcher.rbs +0 -33
  93. data/sig/kobako/rpc/envelope.rbs +0 -51
  94. data/sig/kobako/rpc/fault.rbs +0 -20
  95. data/sig/kobako/rpc/handle.rbs +0 -19
  96. data/sig/kobako/rpc/namespace.rbs +0 -24
  97. data/sig/kobako/rpc/server.rbs +0 -37
  98. data/sig/kobako/wasm.rbs +0 -39
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "outcome/panic"
4
+ require_relative "transport/error"
4
5
 
5
6
  module Kobako
6
7
  # Host-facing boundary for the OUTCOME_BUFFER produced by
@@ -13,7 +14,7 @@ module Kobako
13
14
  # body decoding), and the +Panic+ wire record lives at
14
15
  # +Kobako::Outcome::Panic+. The byte-level msgpack codec at
15
16
  # +Kobako::Codec+ is invoked for the body itself; otherwise
16
- # nothing in +RPC+ participates.
17
+ # nothing in +Transport+ participates.
17
18
  #
18
19
  # * tag 0x01, decode OK → return decoded value
19
20
  # * tag 0x01, decode fails → SandboxError (E-09)
@@ -46,11 +47,19 @@ module Kobako
46
47
 
47
48
  # TrapError for unknown or absent tag
48
49
  # ({docs/wire-codec.md ABI Signatures}[link:../../docs/wire-codec.md]:
49
- # len=0 and unknown-tag both walk the trap path).
50
+ # zero-length output and unrecognised first byte both walk the trap
51
+ # path). The user-facing message stays in caller vocabulary — the
52
+ # raw tag byte (or absence) belongs in +details+ for operators, not
53
+ # in the message a caller sees.
50
54
  def build_trap_error(tag)
51
- return TrapError.new("guest exited without writing an outcome (len=0)") if tag.nil?
52
-
53
- TrapError.new(format("unknown outcome tag 0x%<tag>02x", tag: tag))
55
+ if tag.nil?
56
+ TrapError.new("Sandbox exited without producing a result")
57
+ else
58
+ TrapError.new(
59
+ "Sandbox produced an unrecognised result; the runtime is corrupted, " \
60
+ "discard this Sandbox before another invocation"
61
+ )
62
+ end
54
63
  end
55
64
 
56
65
  def split_tag(bytes)
@@ -63,11 +72,18 @@ module Kobako
63
72
  end
64
73
 
65
74
  # Decode failure on the success tag is a SandboxError (E-09): the
66
- # framing was fine, but the carried value is unrepresentable.
75
+ # framing was fine, but the carried value is unrepresentable. The
76
+ # specific codec fault is stashed in +details+ rather
77
+ # than spliced into the message — callers cannot act on the inner
78
+ # "Symbol payload must be …" wording, but operators triaging a
79
+ # corrupted Sandbox runtime still need it.
67
80
  def decode_value(body)
68
81
  Kobako::Codec::Decoder.decode(body)
69
82
  rescue Kobako::Codec::Error => e
70
- raise build_wire_violation_error("result envelope decode failed: #{e.message}")
83
+ raise build_transport_error(
84
+ "Sandbox produced an invalid result value",
85
+ detail: e.message
86
+ )
71
87
  end
72
88
 
73
89
  # Decode failure on the panic tag is a SandboxError (E-08). Either
@@ -75,20 +91,25 @@ module Kobako
75
91
  # layer exception via +build_panic_error+ and raised; on wire-decode
76
92
  # failure the rescue path raises the wire-violation +SandboxError+.
77
93
  def decode_panic(body)
78
- raise build_panic_error(parse_panic(body))
94
+ raise build_panic_error(build_panic_record(body))
79
95
  rescue Kobako::Codec::Error => e
80
- raise build_wire_violation_error("panic envelope decode failed: #{e.message}")
96
+ raise build_transport_error(
97
+ "Sandbox produced an invalid panic record",
98
+ detail: e.message
99
+ )
81
100
  end
82
101
 
83
102
  # Build a +Panic+ value object from the msgpack-decoded body. Raises
84
- # +Kobako::Codec::InvalidType+ when the body is not a map or
85
- # when required keys are missing — both routed by +decode_panic+ to
86
- # +build_wire_violation_error+.
87
- def parse_panic(body)
88
- map = Kobako::Codec::Decoder.decode(body)
89
- raise Kobako::Codec::InvalidType, "Panic envelope must be a map, got #{map.class}" unless map.is_a?(Hash)
103
+ # +Kobako::Codec::InvalidType+ when the body is not a map or when
104
+ # required keys are missing — both routed by +decode_panic+ to
105
+ # +build_transport_error+. The decode runs in block form so
106
+ # +Panic.new+'s +ArgumentError+ invariants surface as +InvalidType+
107
+ # through the decoder boundary; the message itself is never user-
108
+ # facing it lands in +details+ via the rescue chain above.
109
+ def build_panic_record(body)
110
+ Kobako::Codec::Decoder.decode(body) do |map|
111
+ raise Kobako::Codec::InvalidType, "panic body must be a map, got #{map.class}" unless map.is_a?(Hash)
90
112
 
91
- Kobako::Codec::Utils.wire_boundary do
92
113
  Panic.new(
93
114
  origin: map["origin"], klass: map["class"], message: map["message"],
94
115
  backtrace: map["backtrace"] || [], details: map["details"]
@@ -97,11 +118,8 @@ module Kobako
97
118
  end
98
119
 
99
120
  # Map a decoded Panic record into the corresponding three-layer
100
- # Ruby exception. +origin == "service"+ → ServiceError (with the
101
- # +::Disconnected+ subclass selected when the panic carries the
102
- # disconnected sentinel —
103
- # {docs/behavior.md Error Classes}[link:../../docs/behavior.md]); everything else
104
- # → SandboxError.
121
+ # Ruby exception. +origin == "service"+ → ServiceError; everything
122
+ # else SandboxError.
105
123
  def build_panic_error(panic)
106
124
  panic_target_class(panic).new(
107
125
  panic.message,
@@ -112,28 +130,36 @@ module Kobako
112
130
  )
113
131
  end
114
132
 
115
- # {docs/behavior.md Error Classes}[link:../../docs/behavior.md]: map
133
+ # {docs/behavior.md Error Scenarios}[link:../../docs/behavior.md]: map
116
134
  # the panic +class+ field to the matching Ruby exception subclass so
117
- # callers can rescue specific failure paths. +origin="service"+ plus
118
- # +class="Kobako::ServiceError::Disconnected"+ selects the
119
- # +Disconnected+ subclass (E-14); +origin="sandbox"+ plus
135
+ # callers can rescue specific failure paths. +origin="service"+
136
+ # +ServiceError+; +origin="sandbox"+ plus
120
137
  # +class="Kobako::BytecodeError"+ selects the +BytecodeError+
121
138
  # subclass (E-37 / E-38). Everything else falls back to the base
122
139
  # class for the origin.
123
140
  def panic_target_class(panic)
124
141
  case panic.origin
125
142
  when Panic::ORIGIN_SERVICE
126
- panic.klass == "Kobako::ServiceError::Disconnected" ? ServiceError::Disconnected : ServiceError
143
+ ServiceError
127
144
  else
128
145
  panic.klass == "Kobako::BytecodeError" ? BytecodeError : SandboxError
129
146
  end
130
147
  end
131
148
 
132
- def build_wire_violation_error(message)
133
- SandboxError.new(
149
+ # Lift the wire-violation fallback to the real
150
+ # +Kobako::Transport::Error+ class so callers can +rescue+ it
151
+ # specifically instead of pattern-matching on +error.klass+. The
152
+ # +klass+ field is still populated so existing operator-side
153
+ # tooling that greps on the string continues to work.
154
+ # +detail+ carries the inner codec / framing message, stashed
155
+ # directly into +details+ for operator diagnosis without polluting
156
+ # the user-facing +#message+.
157
+ def build_transport_error(message, detail: nil)
158
+ Kobako::Transport::Error.new(
134
159
  message,
135
160
  origin: Panic::ORIGIN_SANDBOX,
136
- klass: "Kobako::RPC::WireError"
161
+ klass: "Kobako::Transport::Error",
162
+ details: detail
137
163
  )
138
164
  end
139
165
  end
@@ -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
@@ -4,24 +4,27 @@ require "forwardable"
4
4
 
5
5
  require_relative "capture"
6
6
  require_relative "errors"
7
- require_relative "invocation"
8
7
  require_relative "outcome"
9
- require_relative "rpc/server"
10
- require_relative "rpc/envelope"
11
8
  require_relative "sandbox_options"
12
- require_relative "snippet"
9
+ require_relative "usage"
10
+ require_relative "transport"
11
+ require_relative "catalog"
13
12
 
14
13
  module Kobako
15
14
  # Kobako::Sandbox — the user-facing entry point for executing guest mruby
16
15
  # scripts inside a wasmtime-hosted Wasm module
17
16
  # ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
18
17
  #
19
- # The Sandbox owns the +Kobako::Wasm::Instance+, the per-instance RPC Server
20
- # (which itself owns the per-run HandleTable), and the per-channel byte
21
- # caches for guest stdout / stderr capture. The underlying wasmtime Engine
22
- # and compiled Module are cached at process scope by the native ext and
23
- # never surface to Ruby constructing many Sandboxes amortises both costs
24
- # automatically.
18
+ # The Sandbox owns the +Kobako::Runtime+, the per-Sandbox
19
+ # +Kobako::Catalog::Handles+ ({docs/behavior.md B-19}[link:../../docs/behavior.md]),
20
+ # the per-instance +Kobako::Catalog::Namespaces+ (which receives the
21
+ # +Catalog::Handles+ by injection so guest→host dispatch and host→guest
22
+ # auto-wrap share one allocator), and the dispatch +Proc+ /
23
+ # +yield_to_guest+ lambda installed on the Runtime via
24
+ # +Runtime#on_dispatch=+ ({docs/behavior.md B-12}[link:../../docs/behavior.md]).
25
+ # The underlying wasmtime Engine and compiled Module are cached at process
26
+ # scope by the native ext and never surface to Ruby — constructing many
27
+ # Sandboxes amortises both costs automatically.
25
28
  #
26
29
  # Output capture policy ({docs/behavior.md B-04}[link:../../docs/behavior.md]): the
27
30
  # per-channel cap (+stdout_limit+ / +stderr_limit+) is enforced inside the
@@ -34,9 +37,7 @@ module Kobako
34
37
  class Sandbox
35
38
  extend Forwardable
36
39
 
37
- attr_reader :wasm_path, :instance,
38
- :options,
39
- :services, :snippets
40
+ attr_reader :wasm_path, :options
40
41
 
41
42
  # Per-cap accessors forward to the immutable +SandboxOptions+ Value
42
43
  # Object so the Host App still reads them off Sandbox directly.
@@ -72,6 +73,17 @@ module Kobako
72
73
  @stderr_capture.truncated?
73
74
  end
74
75
 
76
+ # Returns the +Kobako::Usage+ value object for the most recent
77
+ # invocation ({docs/behavior.md B-35}[link:../../docs/behavior.md]).
78
+ # Carries +wall_time+ (Float seconds the guest export call spent
79
+ # inside wasmtime) and +memory_peak+ (Integer bytes, high-water of
80
+ # the per-invocation +memory.grow+ delta past the entry-time
81
+ # baseline). Returns +Kobako::Usage::EMPTY+ before any invocation;
82
+ # populated on every outcome — including +TrapError+ — so the Host
83
+ # App can read it after rescuing a trap to diagnose budget
84
+ # consumption.
85
+ attr_reader :usage
86
+
75
87
  # Build a fresh Sandbox.
76
88
  #
77
89
  # +wasm_path+ is the absolute path to the Guest Binary; defaults to the
@@ -84,20 +96,22 @@ module Kobako
84
96
  def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil,
85
97
  timeout: SandboxOptions::DEFAULT_TIMEOUT_SECONDS,
86
98
  memory_limit: SandboxOptions::DEFAULT_MEMORY_LIMIT)
87
- @wasm_path = wasm_path || Kobako::Wasm.default_path
99
+ @wasm_path = wasm_path || Kobako::Runtime.default_path
88
100
  @options = SandboxOptions.new(timeout: timeout, memory_limit: memory_limit, stdout_limit: stdout_limit,
89
101
  stderr_limit: stderr_limit)
90
- @services = Kobako::RPC::Server.new
91
- @snippets = Snippet::Table.new
92
- @instance = Kobako::Wasm::Instance.from_path(@wasm_path, @options.timeout, @options.memory_limit,
93
- @options.stdout_limit, @options.stderr_limit)
94
- @instance.server = @services
95
- clear_captures!
102
+ @handler = Catalog::Handles.new
103
+ @services = Kobako::Catalog::Namespaces.new(handler: @handler)
104
+ @snippets = Catalog::Snippets.new
105
+ @runtime = Kobako::Runtime.from_path(@wasm_path, @options.timeout, @options.memory_limit,
106
+ @options.stdout_limit, @options.stderr_limit)
107
+ install_dispatch_proc!
108
+ reset_invocation_state!
96
109
  end
97
110
 
98
111
  # Declare or retrieve the Namespace named +name+ on this Sandbox
99
112
  # ({docs/behavior.md B-07, B-09, B-10}[link:../../docs/behavior.md]). +name+ must be a
100
- # Symbol or String in constant form. Returns the +Kobako::RPC::Namespace+.
113
+ # Symbol or String in constant form. Returns the
114
+ # +Kobako::Namespace+.
101
115
  #
102
116
  # Raises +ArgumentError+ when called after the first invocation, or
103
117
  # when +name+ does not match the constant-name pattern.
@@ -144,17 +158,19 @@ module Kobako
144
158
 
145
159
  # Dispatch into a preloaded entrypoint constant
146
160
  # ({docs/behavior.md B-31}[link:../../docs/behavior.md]). Delegates host
147
- # pre-flight (E-24 / E-25 / E-29 / E-30) and wire encoding to
148
- # +Kobako::Invocation+ / +Kobako::Invocation#encode+; the guest
149
- # resolves +target+ as a top-level constant, calls +#call+ on it
150
- # with +args+ / +kwargs+, and returns the deserialized result. The
151
- # first invocation seals the Service registry and snippet table
152
- # (B-07 / B-33). Runtime errors follow the same three-class taxonomy
153
- # as +#eval+.
161
+ # pre-flight and wire encoding to +Kobako::Transport::Run+ /
162
+ # +Kobako::Transport::Run#encode+: a non-Symbol/String +target+ raises
163
+ # +TypeError+ (E-24), while a +target+ failing the constant pattern
164
+ # (E-25), a forged +Kobako::Handle+ in +args+ / +kwargs+ (E-29), or a
165
+ # non-Symbol +kwargs+ key (E-30) raise +ArgumentError+. The guest
166
+ # resolves +target+ as a top-level constant, calls +#call+ on it with
167
+ # +args+ / +kwargs+, and returns the deserialized result. The first
168
+ # invocation seals the Service registry and snippet table (B-07 /
169
+ # B-33). Runtime errors follow the same three-class taxonomy as +#eval+.
154
170
  def run(target, *args, **kwargs)
155
- invocation = Invocation.new(entrypoint: target, args: args, kwargs: kwargs)
171
+ run_envelope = Transport::Run.new(entrypoint: target, args: args, kwargs: kwargs)
156
172
  invoke!(:run) do
157
- @instance.run(@services.encoded_preamble, @snippets.encode, invocation.encode)
173
+ @runtime.run(@services.encode, @snippets.encode, run_envelope.encode(@handler))
158
174
  end
159
175
  end
160
176
 
@@ -185,66 +201,114 @@ module Kobako
185
201
  raise SandboxError, "code must be a String, got #{code.class}" unless code.is_a?(String)
186
202
 
187
203
  invoke!(:eval) do
188
- @instance.eval(@services.encoded_preamble, code.b, @snippets.encode)
204
+ @runtime.eval(@services.encode, code.b, @snippets.encode)
189
205
  end
190
206
  end
191
207
 
192
208
  private
193
209
 
210
+ # Configure the +Runtime+'s host↔guest dispatch wiring
211
+ # ({docs/behavior.md B-12}[link:../../docs/behavior.md]). Builds a
212
+ # lambda that re-enters the guest via
213
+ # +Runtime#yield_to_active_invocation+ (B-24) and a dispatch +Proc+
214
+ # that routes guest→host calls through the stateless
215
+ # +Transport::Dispatcher+, capturing +@services+ / +@handler+ in the
216
+ # closure. Both are registered on the +Runtime+ once at construction
217
+ # time so the wasm ext callback can fire without further setup.
218
+ def install_dispatch_proc!
219
+ yield_to_guest = ->(args_bytes) { @runtime.yield_to_active_invocation(args_bytes) }
220
+ @runtime.on_dispatch = lambda do |request_bytes|
221
+ Transport::Dispatcher.dispatch(request_bytes, @services, @handler, yield_to_guest)
222
+ end
223
+ end
224
+
194
225
  # Per-invocation prologue ({docs/behavior.md B-03 / B-07 /
195
226
  # B-33}[link:../../docs/behavior.md]). Seals the Service / snippet
196
227
  # registries on first call (idempotent) and zeros the per-invocation
197
228
  # capability state — capture buffers, truncation predicates, and the
198
- # HandleTable counter — before the guest runs.
229
+ # +Catalog::Handles+ counter — before the guest runs. The
230
+ # +Catalog::Handles+ itself is held as +@handler+ and never exposed beyond
231
+ # this class: SPEC.md Terminology pins it as "Not exposed to the
232
+ # Host App" (B-19 / B-20 / E-29).
199
233
  def begin_invocation!
200
234
  @services.seal!
201
- @services.reset_handles!
202
- clear_captures!
235
+ @handler.reset!
236
+ reset_invocation_state!
203
237
  end
204
238
 
205
- # Reset both per-channel captures to the pre-invocation sentinel
206
- # ({docs/behavior.md B-05}[link:../../docs/behavior.md]). Shared by +#initialize+
207
- # (first-time setup) and +#begin_invocation!+ (between-invocation
208
- # reset) so both paths agree on what "empty capture" means.
209
- def clear_captures!
239
+ # Reset all per-invocation observable state to its pre-invocation
240
+ # sentinels both per-channel captures
241
+ # ({docs/behavior.md B-05}[link:../../docs/behavior.md]) and the
242
+ # per-last-invocation usage record
243
+ # ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Shared by
244
+ # +#initialize+ (first-time setup) and +#begin_invocation!+
245
+ # (between-invocation reset) so both paths agree on what
246
+ # "pre-invocation state" means.
247
+ def reset_invocation_state!
210
248
  @stdout_capture = Capture::EMPTY
211
249
  @stderr_capture = Capture::EMPTY
250
+ @usage = Usage::EMPTY
212
251
  end
213
252
 
214
- # Read the per-channel capture pairs (+[bytes, truncated]+) from the
215
- # ext after an invocation completes and wrap each as a +Capture+ value
216
- # object. The ext clips +bytes+ to the configured cap and sets
217
- # +truncated+ when the guest produced strictly more than +cap+ bytes
218
- # ({docs/behavior.md B-04}[link:../../docs/behavior.md]). Mirror of {#clear_captures!}
219
- # at the post-invocation boundary.
220
- def read_captures!
221
- out_bytes, out_truncated = @instance.stdout
222
- err_bytes, err_truncated = @instance.stderr
223
- @stdout_capture = Capture.from_ext(out_bytes, out_truncated)
224
- @stderr_capture = Capture.from_ext(err_bytes, err_truncated)
253
+ # Read the per-last-invocation +wall_time+ and +memory_peak+ from
254
+ # the ext and wrap them as a +Kobako::Usage+ value object
255
+ # ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Runs in
256
+ # the +invoke!+ +ensure+ block so the usage record is populated on
257
+ # every outcome value return, +Kobako::TrapError+ (including
258
+ # +TimeoutError+ / +MemoryLimitError+), +Kobako::SandboxError+,
259
+ # and +Kobako::ServiceError+. On the success path the same figures
260
+ # already arrive via +Snapshot#usage+; on the trap path the Snapshot
261
+ # never reaches Ruby so the ext readout here is the only source.
262
+ #
263
+ # The ext returns a positional 2-tuple +[wall_time, memory_peak]+
264
+ # whose order matches the +Kobako::Usage+ field order; the
265
+ # destructure-then-kwargs handoff below is the explicit
266
+ # positional→keyword conversion point.
267
+ def read_usage!
268
+ wall_time, memory_peak = @runtime.usage
269
+ @usage = Usage.new(wall_time: wall_time, memory_peak: memory_peak)
270
+ end
271
+
272
+ # Pick the +TrapError+ subclass to re-raise based on +err+'s actual
273
+ # class. Cap-trap subclasses
274
+ # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md])
275
+ # preserve their named identity; everything else collapses to the
276
+ # base +Kobako::TrapError+. The ext already raises the right subclass
277
+ # directly, so this is a pure re-attribution that lets +#invoke!+
278
+ # add the verb prefix without erasing +TimeoutError+ /
279
+ # +MemoryLimitError+.
280
+ def trap_class_for(err)
281
+ case err
282
+ when TimeoutError then TimeoutError
283
+ when MemoryLimitError then MemoryLimitError
284
+ else TrapError
285
+ end
225
286
  end
226
287
 
227
288
  # Shared prologue / epilogue + trap-class translator for both
228
289
  # invocation verbs. +verb+ is +:eval+ or +:run+; it tags the
229
- # TrapError message so the failing export is identifiable. The
230
- # rescue chain is the single trap-translation boundary — wasmtime /
231
- # wire failures from the guest call and from the subsequent
232
- # +Instance#outcome!+ read both flow through here, so an
233
- # OUTCOME_BUFFER read failure attributes to the same export name as
234
- # the guest call itself. Configured-cap paths
235
- # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md]) surface as
236
- # named TrapError subclasses.
290
+ # TrapError message so the failing export is identifiable.
291
+ #
292
+ # The yielded block must return a +Kobako::Snapshot+ i.e. the
293
+ # value of +Runtime#eval+ / +#run+ (SPEC.md Internal Concepts
294
+ # Snapshot). The success path unpacks every observable from the
295
+ # Snapshot in one go: +#stdout+ / +#stderr+ pack into +Capture+,
296
+ # +#usage+ packs into +Usage+, +#return_bytes+ feeds +Outcome.decode+.
297
+ # The rescue chain is the single trap-translation boundary —
298
+ # configured-cap paths
299
+ # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md])
300
+ # surface as named TrapError subclasses; everything else surfaces as
301
+ # the base +TrapError+.
237
302
  def invoke!(verb)
238
303
  begin_invocation!
239
- yield
240
- read_captures!
241
- Outcome.decode(@instance.outcome!)
242
- rescue Kobako::Wasm::TimeoutError => e
243
- raise TimeoutError, "guest exceeded timeout: #{e.message}"
244
- rescue Kobako::Wasm::MemoryLimitError => e
245
- raise MemoryLimitError, "guest exceeded memory_limit: #{e.message}"
246
- rescue Kobako::Wasm::Error => e
247
- raise TrapError, "guest __kobako_#{verb} trapped: #{e.message}"
304
+ snapshot = yield
305
+ @stdout_capture = snapshot.stdout
306
+ @stderr_capture = snapshot.stderr
307
+ Outcome.decode(snapshot.return_bytes)
308
+ rescue Kobako::TrapError => e
309
+ raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
310
+ ensure
311
+ read_usage!
248
312
  end
249
313
  end
250
314
  end
@@ -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