kobako 0.1.2 → 0.2.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +95 -60
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +1 -1
  6. data/ext/kobako/src/wasm/cache.rs +39 -1
  7. data/ext/kobako/src/wasm/dispatch.rs +20 -20
  8. data/ext/kobako/src/wasm/host_state.rs +261 -34
  9. data/ext/kobako/src/wasm/instance.rs +467 -272
  10. data/ext/kobako/src/wasm.rs +50 -19
  11. data/lib/kobako/capture.rb +46 -0
  12. data/lib/kobako/codec/decoder.rb +66 -0
  13. data/lib/kobako/codec/encoder.rb +37 -0
  14. data/lib/kobako/codec/error.rb +33 -0
  15. data/lib/kobako/codec/factory.rb +155 -0
  16. data/lib/kobako/codec/utils.rb +55 -0
  17. data/lib/kobako/codec.rb +27 -0
  18. data/lib/kobako/errors.rb +24 -1
  19. data/lib/kobako/outcome/panic.rb +42 -0
  20. data/lib/kobako/outcome.rb +133 -0
  21. data/lib/kobako/rpc/dispatcher.rb +169 -0
  22. data/lib/kobako/rpc/envelope.rb +118 -0
  23. data/lib/kobako/{wire/exception.rb → rpc/fault.rb} +6 -4
  24. data/lib/kobako/{wire → rpc}/handle.rb +4 -2
  25. data/lib/kobako/{registry → rpc}/handle_table.rb +9 -9
  26. data/lib/kobako/{registry/service_group.rb → rpc/namespace.rb} +20 -11
  27. data/lib/kobako/rpc/server.rb +156 -0
  28. data/lib/kobako/rpc.rb +11 -0
  29. data/lib/kobako/sandbox.rb +149 -69
  30. data/lib/kobako/version.rb +1 -1
  31. data/lib/kobako/wasm.rb +6 -16
  32. data/lib/kobako.rb +2 -0
  33. data/sig/kobako/capture.rbs +13 -0
  34. data/sig/kobako/codec/decoder.rbs +11 -0
  35. data/sig/kobako/codec/encoder.rbs +7 -0
  36. data/sig/kobako/codec/error.rbs +18 -0
  37. data/sig/kobako/codec/factory.rbs +31 -0
  38. data/sig/kobako/codec/utils.rbs +9 -0
  39. data/sig/kobako/errors.rbs +52 -0
  40. data/sig/kobako/outcome/panic.rbs +34 -0
  41. data/sig/kobako/outcome.rbs +24 -0
  42. data/sig/kobako/rpc/dispatcher.rbs +33 -0
  43. data/sig/kobako/rpc/envelope.rbs +51 -0
  44. data/sig/kobako/rpc/fault.rbs +20 -0
  45. data/sig/kobako/rpc/handle.rbs +19 -0
  46. data/sig/kobako/rpc/handle_table.rbs +25 -0
  47. data/sig/kobako/rpc/namespace.rbs +24 -0
  48. data/sig/kobako/rpc/server.rbs +37 -0
  49. data/sig/kobako/rpc.rbs +4 -0
  50. data/sig/kobako/sandbox.rbs +53 -0
  51. data/sig/kobako/wasm.rbs +37 -0
  52. data/sig/kobako.rbs +0 -1
  53. metadata +37 -17
  54. data/lib/kobako/registry/dispatcher.rb +0 -168
  55. data/lib/kobako/registry.rb +0 -160
  56. data/lib/kobako/sandbox/outcome_decoder.rb +0 -100
  57. data/lib/kobako/sandbox/output_buffer.rb +0 -79
  58. data/lib/kobako/wire/codec/decoder.rb +0 -87
  59. data/lib/kobako/wire/codec/encoder.rb +0 -41
  60. data/lib/kobako/wire/codec/error.rb +0 -35
  61. data/lib/kobako/wire/codec/factory.rb +0 -136
  62. data/lib/kobako/wire/codec.rb +0 -44
  63. data/lib/kobako/wire/envelope/payloads.rb +0 -145
  64. data/lib/kobako/wire/envelope.rb +0 -147
  65. data/lib/kobako/wire.rb +0 -40
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "msgpack"
4
+ require_relative "../errors"
5
+ require_relative "envelope"
6
+ require_relative "namespace"
7
+ require_relative "handle_table"
8
+ require_relative "dispatcher"
9
+
10
+ module Kobako
11
+ module RPC
12
+ # Kobako::RPC::Server — per-Sandbox host-side RPC coordinator. Maintains
13
+ # the Namespace / Member registry, owns the HandleTable, and routes
14
+ # incoming Requests to the resolved Service object
15
+ # ({SPEC.md B-07..B-21}[link:../../../SPEC.md]).
16
+ #
17
+ # Public API:
18
+ #
19
+ # server = Kobako::RPC::Server.new
20
+ # namespace = server.define(:MyService) # => Kobako::RPC::Namespace
21
+ # namespace.bind(:KV, kv_object) # => namespace (chainable)
22
+ # server.to_preamble # => array for Frame 1
23
+ # server.dispatch(request_bytes) # => msgpack bytes (delegated to Dispatcher)
24
+ #
25
+ # Namespaces live at +Kobako::RPC::Namespace+
26
+ # (lib/kobako/rpc/namespace.rb). The opaque Handle allocator lives at
27
+ # +Kobako::RPC::HandleTable+
28
+ # (lib/kobako/rpc/handle_table.rb). Dispatch helpers live at
29
+ # +Kobako::RPC::Dispatcher+
30
+ # (lib/kobako/rpc/dispatcher.rb).
31
+ class Server
32
+ # Build a fresh Server. +handle_table+ is an internal seam that
33
+ # injects a pre-configured +HandleTable+; tests pass one whose +next_id+
34
+ # is pinned near +MAX_ID+ to exercise the B-21 cap-exhaustion path
35
+ # without 2³¹ allocations. Production callers leave it at the default.
36
+ def initialize(handle_table: HandleTable.new)
37
+ @namespaces = {} # : Hash[String, Kobako::RPC::Namespace]
38
+ @handle_table = handle_table
39
+ @sealed = false
40
+ end
41
+
42
+ # Declare or retrieve the Namespace named +name+ (idempotent — SPEC.md B-10).
43
+ # +name+ is a constant-form name as a +Symbol+ or +String+ (must satisfy
44
+ # +Namespace::NAME_PATTERN+). Returns the +Kobako::RPC::Namespace+ for
45
+ # that name, creating it if it does not exist. Raises +ArgumentError+
46
+ # when +name+ is malformed, or when called after the owning Sandbox has
47
+ # been sealed by +#run+.
48
+ def define(name)
49
+ raise ArgumentError, "cannot define after Sandbox#run has been invoked" if @sealed
50
+
51
+ name_str = name.to_s
52
+ unless Namespace::NAME_PATTERN.match?(name_str)
53
+ raise ArgumentError,
54
+ "Namespace name must match #{Namespace::NAME_PATTERN.inspect} (got #{name.inspect})"
55
+ end
56
+
57
+ @namespaces[name_str] ||= Namespace.new(name_str)
58
+ end
59
+
60
+ # Resolve a +target+ path of the form +"Namespace::Member"+ to the
61
+ # bound Host object. +target+ is a two-level path using the +::+
62
+ # separator. Returns the bound Host object. Raises +KeyError+ when the
63
+ # namespace or the member is not bound.
64
+ def lookup(target)
65
+ namespace, member_name, namespace_name = parse_target(target)
66
+ raise KeyError, "no namespace named #{namespace_name.inspect}" if namespace.nil?
67
+ raise KeyError, "no member #{target.inspect} bound on server" unless member_name
68
+
69
+ namespace.fetch(member_name)
70
+ end
71
+
72
+ # Returns +true+ when +target+ (a +"Namespace::Member"+ path) resolves
73
+ # to a bound member, +false+ otherwise.
74
+ def bound?(target)
75
+ namespace, member_name, = parse_target(target)
76
+ !namespace.nil? && !member_name.nil? && !namespace[member_name].nil?
77
+ end
78
+
79
+ # Returns all declared +Kobako::RPC::Namespace+ instances as an +Array+.
80
+ def namespaces
81
+ @namespaces.values
82
+ end
83
+
84
+ # Returns the number of declared namespaces as an +Integer+.
85
+ def size
86
+ @namespaces.size
87
+ end
88
+
89
+ # Returns +true+ when no namespaces have been declared, +false+ otherwise.
90
+ def empty?
91
+ @namespaces.empty?
92
+ end
93
+
94
+ # Structured Frame 1 description. Called by +Sandbox#run+ when assembling
95
+ # stdin Frame 1 ({SPEC.md B-02}[link:../../../SPEC.md]). Returns an
96
+ # unencoded preamble array — an +Array+ of two-element +[name, members]+
97
+ # arrays, one per declared namespace.
98
+ def to_preamble
99
+ @namespaces.values.map(&:to_preamble)
100
+ end
101
+
102
+ # Encode the preamble as msgpack bytes for stdin Frame 1 delivery
103
+ # ({SPEC.md B-02}[link:../../../SPEC.md]). Uses plain MessagePack (no
104
+ # kobako ext types) because the preamble contains only strings — no
105
+ # Handles or Fault envelopes. Structure:
106
+ # +[["Namespace", ["MemberA", "MemberB"]], ...]+. Returns a binary
107
+ # +String+ of msgpack bytes.
108
+ def encoded_preamble
109
+ MessagePack.pack(to_preamble)
110
+ end
111
+
112
+ # Mark the Server as sealed. Called by +Sandbox#run+ on first run.
113
+ # After sealing, #define raises ArgumentError. Idempotent.
114
+ def seal!
115
+ @sealed = true
116
+ self
117
+ end
118
+
119
+ # Returns +true+ when {#seal!} has been called, +false+ otherwise.
120
+ def sealed?
121
+ @sealed
122
+ end
123
+
124
+ # Reset the HandleTable for a new +#run+ boundary. Called by +Sandbox#run+
125
+ # before each invocation ({SPEC.md B-19}[link:../../../SPEC.md]).
126
+ def reset_handles!
127
+ @handle_table.reset!
128
+ end
129
+
130
+ # Dispatch a single RPC request and return the encoded response bytes
131
+ # ({SPEC.md B-12}[link:../../../SPEC.md]). +request_bytes+ is a
132
+ # msgpack-encoded Request envelope. Called by the Rust ext from inside
133
+ # +__kobako_dispatch+. Always returns a binary +String+ — never raises.
134
+ # Delegates to +Dispatcher.dispatch+ which reifies any failure as a
135
+ # +Response.error+ envelope so the guest sees the failure as a normal RPC
136
+ # error rather than a wasm trap.
137
+ def dispatch(request_bytes)
138
+ Dispatcher.dispatch(request_bytes, self)
139
+ end
140
+
141
+ # Expose the +HandleTable+ for tests and wire-layer Handle wrapping.
142
+ attr_reader :handle_table
143
+
144
+ private
145
+
146
+ # Split +target+ on the +::+ separator and resolve the namespace half.
147
+ # Returns +[namespace_or_nil, member_str_or_nil, namespace_name_str]+ so
148
+ # each public method ({#lookup} / {#bound?}) only owns its boundary
149
+ # semantics (raise vs predicate).
150
+ def parse_target(target)
151
+ namespace_name, member_name = target.to_s.split("::", 2)
152
+ [@namespaces[namespace_name], member_name, namespace_name]
153
+ end
154
+ end
155
+ end
156
+ end
data/lib/kobako/rpc.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ # Kobako::RPC — protocol namespace for host↔guest RPC. Houses the value
5
+ # objects that travel on the wire (+Handle+, +Request+, +Response+,
6
+ # +Fault+) and the host-side Server coordinator. See
7
+ # {SPEC.md Refinement → Internal Concepts}[link:../../SPEC.md] for the
8
+ # RPC role split.
9
+ module RPC
10
+ end
11
+ end
@@ -1,73 +1,105 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "capture"
3
4
  require_relative "errors"
4
- require_relative "registry"
5
- require_relative "wire"
6
- require_relative "sandbox/output_buffer"
7
- require_relative "sandbox/outcome_decoder"
5
+ require_relative "outcome"
6
+ require_relative "rpc/server"
7
+ require_relative "rpc/envelope"
8
8
 
9
9
  module Kobako
10
10
  # Kobako::Sandbox — the user-facing entry point for executing guest mruby
11
11
  # scripts inside a wasmtime-hosted Wasm module
12
12
  # ({SPEC.md B-01}[link:../../SPEC.md]).
13
13
  #
14
- # The Sandbox owns the +Kobako::Wasm::Instance+, the per-instance Registry
15
- # (which itself owns the per-run HandleTable), and bounded stdout / stderr
16
- # capture buffers. The underlying wasmtime Engine and compiled Module are
17
- # cached at process scope by the native ext and never surface to Ruby —
18
- # constructing many Sandboxes amortises both costs automatically.
14
+ # The Sandbox owns the +Kobako::Wasm::Instance+, the per-instance RPC Server
15
+ # (which itself owns the per-run HandleTable), and the per-channel byte
16
+ # caches for guest stdout / stderr capture. The underlying wasmtime Engine
17
+ # and compiled Module are cached at process scope by the native ext and
18
+ # never surface to Ruby — constructing many Sandboxes amortises both costs
19
+ # automatically.
19
20
  #
20
- # Buffer overflow policy ({SPEC.md B-04}[link:../../SPEC.md]): once an
21
- # append would push the cumulative byte count past the per-channel
22
- # `*_limit`, the OutputBuffer truncates it stores a prefix that fits
23
- # under the cap and appends a +[truncated]+ marker on the next read.
24
- # Truncation does NOT raise. The marker constant lives on
25
- # +Kobako::Sandbox::OutputBuffer::OUTPUT_TRUNCATION_MARKER+.
21
+ # Output capture policy ({SPEC.md B-04}[link:../../SPEC.md]): the
22
+ # per-channel cap (+stdout_limit+ / +stderr_limit+) is enforced inside the
23
+ # WASI pipe — the host buffer stops growing at the cap, subsequent guest
24
+ # writes on that channel fail or are dropped, and +#run+ still returns
25
+ # normally. +#stdout+ / +#stderr+ return the captured prefix as a UTF-8
26
+ # String; the byte content never carries a truncation sentinel.
27
+ # +#stdout_truncated?+ / +#stderr_truncated?+ are the only way to observe
28
+ # that the cap was hit.
26
29
  class Sandbox
27
30
  # Default per-channel capture ceiling: 1 MiB
28
- # ({SPEC.md B-01 footnote}[link:../../SPEC.md]).
31
+ # ({SPEC.md B-01}[link:../../SPEC.md]).
29
32
  DEFAULT_OUTPUT_LIMIT = 1 << 20
30
33
 
34
+ # Default wall-clock timeout for a single +#run+: 60 seconds
35
+ # ({SPEC.md B-01}[link:../../SPEC.md]).
36
+ DEFAULT_TIMEOUT_SECONDS = 60.0
37
+
38
+ # Default cap on guest linear memory growth: 5 MiB
39
+ # ({SPEC.md B-01}[link:../../SPEC.md]).
40
+ DEFAULT_MEMORY_LIMIT = 5 << 20
41
+
31
42
  attr_reader :wasm_path, :instance,
32
- :stdout_buffer, :stderr_buffer,
33
- :stdout_limit, :stderr_limit, :services
43
+ :stdout_limit, :stderr_limit,
44
+ :timeout, :memory_limit, :services
34
45
 
35
- # Returns the complete byte content guest wrote to stdout during the most
36
- # recent +#run+ as a UTF-8 String, or an empty String before any +#run+
37
- # call. {SPEC.md B-04}[link:../../SPEC.md]: may contain a +[truncated]+
38
- # marker when the cap was hit.
46
+ # Returns the bytes the guest wrote to stdout during the most recent
47
+ # +#run+ as a UTF-8 String, clipped at +stdout_limit+. Empty before any
48
+ # +#run+ call. {SPEC.md B-04}[link:../../SPEC.md] the byte content
49
+ # never contains a truncation sentinel; use +#stdout_truncated?+ to
50
+ # observe overflow.
39
51
  def stdout
40
- @stdout_buffer.to_s
52
+ @stdout_capture.bytes
41
53
  end
42
54
 
43
- # Returns the complete byte content guest wrote to stderr during the most
44
- # recent +#run+ as a UTF-8 String, or an empty String before any +#run+
45
- # call. {SPEC.md B-04}[link:../../SPEC.md]: may contain a +[truncated]+
46
- # marker when the cap was hit.
55
+ # Returns the bytes the guest wrote to stderr during the most recent
56
+ # +#run+ as a UTF-8 String, clipped at +stderr_limit+. Empty before any
57
+ # +#run+ call. Mirror of +#stdout+.
47
58
  def stderr
48
- @stderr_buffer.to_s
59
+ @stderr_capture.bytes
60
+ end
61
+
62
+ # Returns +true+ iff stdout capture during the most recent +#run+
63
+ # exceeded +stdout_limit+ ({SPEC.md B-04}[link:../../SPEC.md]). Resets
64
+ # to +false+ at the start of the next +#run+ ({SPEC.md
65
+ # B-03}[link:../../SPEC.md]).
66
+ def stdout_truncated?
67
+ @stdout_capture.truncated?
68
+ end
69
+
70
+ # Returns +true+ iff stderr capture during the most recent +#run+
71
+ # exceeded +stderr_limit+. Mirror of +#stdout_truncated?+.
72
+ def stderr_truncated?
73
+ @stderr_capture.truncated?
49
74
  end
50
75
 
51
76
  # Build a fresh Sandbox.
52
77
  #
53
78
  # +wasm_path+ is the absolute path to the Guest Binary; defaults to the
54
79
  # gem-bundled +data/kobako.wasm+. +stdout_limit+ and +stderr_limit+ cap
55
- # the per-run byte ceiling for each capture channel (default 1 MiB).
56
- def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil)
80
+ # the per-run byte ceiling for each capture channel (default 1 MiB;
81
+ # +nil+ disables the cap). +timeout+ is the wall-clock cap on a single
82
+ # +#run+ in seconds ({SPEC.md B-01}[link:../../SPEC.md]; default 60.0,
83
+ # +nil+ disables); +memory_limit+ caps guest linear memory growth in
84
+ # bytes ({SPEC.md B-01, E-20}[link:../../SPEC.md]; default 5 MiB,
85
+ # +nil+ disables).
86
+ def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil,
87
+ timeout: DEFAULT_TIMEOUT_SECONDS,
88
+ memory_limit: DEFAULT_MEMORY_LIMIT)
57
89
  @wasm_path = wasm_path || Kobako::Wasm.default_path
58
90
  @stdout_limit = stdout_limit || DEFAULT_OUTPUT_LIMIT
59
91
  @stderr_limit = stderr_limit || DEFAULT_OUTPUT_LIMIT
60
- @instance = Kobako::Wasm::Instance.from_path(@wasm_path)
61
- @stdout_buffer = OutputBuffer.new(@stdout_limit)
62
- @stderr_buffer = OutputBuffer.new(@stderr_limit)
63
- @services = Kobako::Registry.new
64
- @instance.set_registry(@services)
92
+ @timeout = normalize_timeout(timeout)
93
+ @memory_limit = normalize_memory_limit(memory_limit)
94
+ @services = Kobako::RPC::Server.new
95
+ @instance = Kobako::Wasm::Instance.from_path(@wasm_path, @timeout, @memory_limit, @stdout_limit, @stderr_limit)
96
+ @instance.server = @services
97
+ clear_captures!
65
98
  end
66
99
 
67
- # Declare or retrieve the Service Group named +name+ on this Sandbox
100
+ # Declare or retrieve the Namespace named +name+ on this Sandbox
68
101
  # ({SPEC.md B-07, B-09, B-10}[link:../../SPEC.md]). +name+ must be a
69
- # Symbol or String in constant form. Returns the
70
- # +Kobako::Registry::ServiceGroup+.
102
+ # Symbol or String in constant form. Returns the +Kobako::RPC::Namespace+.
71
103
  #
72
104
  # Raises +ArgumentError+ when called after +#run+, or when +name+ does
73
105
  # not match the constant-name pattern.
@@ -82,7 +114,7 @@ module Kobako
82
114
  #
83
115
  # Source delivery uses the WASI stdin two-frame protocol
84
116
  # ({SPEC.md ABI Signatures}[link:../../SPEC.md]): Frame 1 carries the
85
- # msgpack-encoded preamble (Service Group registry snapshot) and Frame 2
117
+ # msgpack-encoded preamble (Namespace / Member registry snapshot) and Frame 2
86
118
  # carries the user script UTF-8 bytes. Each frame is prefixed by a
87
119
  # 4-byte big-endian u32 length.
88
120
  #
@@ -94,53 +126,101 @@ module Kobako
94
126
 
95
127
  @services.seal!
96
128
  reset_run_state!
97
- preamble = @services.guest_preamble
98
- @instance.setup_wasi_pipes(@stdout_limit, @stderr_limit, preamble, source.b)
99
129
 
100
- invoke_guest_run
101
- drain_wasi_output
102
- outcome_bytes = read_outcome_bytes
103
- OutcomeDecoder.decode(outcome_bytes)
130
+ run_guest(@services.encoded_preamble, source.b)
131
+ read_captures!
132
+ take_result!
104
133
  end
105
134
 
106
135
  private
107
136
 
137
+ # Coerce +timeout+ into the Float seconds the ext expects, or +nil+ to
138
+ # mean the cap is disabled ({SPEC.md B-01}[link:../../SPEC.md]). Any
139
+ # finite non-positive value is rejected — a zero or negative timeout
140
+ # would either fire instantly or never, both of which would surprise
141
+ # callers more than an early +ArgumentError+.
142
+ def normalize_timeout(timeout)
143
+ return nil if timeout.nil?
144
+ raise ArgumentError, "timeout must be Numeric or nil, got #{timeout.class}" unless timeout.is_a?(Numeric)
145
+
146
+ seconds = timeout.to_f
147
+ raise ArgumentError, "timeout must be > 0 (got #{timeout})" unless seconds.positive? && seconds.finite?
148
+
149
+ seconds
150
+ end
151
+
152
+ # Coerce +memory_limit+ into the byte cap the ext expects, or +nil+ to
153
+ # mean unbounded ({SPEC.md B-01, E-20}[link:../../SPEC.md]). Must be a
154
+ # positive Integer when set; +Float+ or zero/negative values are
155
+ # rejected.
156
+ def normalize_memory_limit(memory_limit)
157
+ return nil if memory_limit.nil?
158
+ unless memory_limit.is_a?(Integer) && memory_limit.positive?
159
+ raise ArgumentError, "memory_limit must be a positive Integer or nil, got #{memory_limit.inspect}"
160
+ end
161
+
162
+ memory_limit
163
+ end
164
+
108
165
  # Per-run state reset ({SPEC.md B-03}[link:../../SPEC.md]). Capture
109
- # buffers and the HandleTable counter are zeroed before the guest runs.
166
+ # buffers, truncation predicates, and the HandleTable counter are
167
+ # zeroed before the guest runs.
110
168
  def reset_run_state!
111
169
  @services.reset_handles!
112
- @stdout_buffer.clear
113
- @stderr_buffer.clear
170
+ clear_captures!
114
171
  end
115
172
 
116
- # Drain the WASI stdout/stderr pipes populated during the most recent
117
- # guest execution into the bounded OutputBuffers
118
- # ({SPEC.md B-04}[link:../../SPEC.md]). Must be called after
119
- # `invoke_guest_run` and before the next reset.
120
- def drain_wasi_output
121
- stdout_bytes = @instance.take_stdout
122
- stderr_bytes = @instance.take_stderr
123
- @stdout_buffer << stdout_bytes unless stdout_bytes.empty?
124
- @stderr_buffer << stderr_bytes unless stderr_bytes.empty?
173
+ # Reset both per-channel captures to the pre-run sentinel
174
+ # ({SPEC.md B-05}[link:../../SPEC.md]). Shared by +#initialize+
175
+ # (first-run setup) and +#reset_run_state!+ (between-run reset) so
176
+ # both paths agree on what "empty capture" means.
177
+ def clear_captures!
178
+ @stdout_capture = Capture::EMPTY
179
+ @stderr_capture = Capture::EMPTY
125
180
  end
126
181
 
127
- # Invoke `__kobako_run`. Wraps wasmtime / wire errors in TrapError.
128
- # Source was already delivered via the stdin two-frame protocol in
129
- # `setup_wasi_pipes` before this call
130
- # ({SPEC.md ABI Signatures}[link:../../SPEC.md]).
131
- def invoke_guest_run
132
- @instance.run
182
+ # Read the per-channel capture pairs (+[bytes, truncated]+) from the
183
+ # ext after a guest run completes and wrap each as a +Capture+ value
184
+ # object. The ext clips +bytes+ to the configured cap and sets
185
+ # +truncated+ when the guest produced strictly more than +cap+ bytes
186
+ # ({SPEC.md B-04}[link:../../SPEC.md]). Mirror of {#clear_captures!}
187
+ # at the post-run boundary.
188
+ def read_captures!
189
+ out_bytes, out_truncated = @instance.stdout
190
+ err_bytes, err_truncated = @instance.stderr
191
+ @stdout_capture = Capture.from_ext(out_bytes, out_truncated)
192
+ @stderr_capture = Capture.from_ext(err_bytes, err_truncated)
193
+ end
194
+
195
+ # Drive +Instance#run+ with the two stdin frames (preamble + source).
196
+ # Wraps wasmtime / wire errors in TrapError so the Sandbox layer maps
197
+ # cleanly to the three-class taxonomy. The configured-cap paths
198
+ # (SPEC.md E-19 / E-20) are routed to the named TrapError subclasses
199
+ # so callers that want to surface a specific reason can rescue them;
200
+ # everything else falls through to the base TrapError.
201
+ def run_guest(preamble, source)
202
+ @instance.run(preamble, source)
203
+ rescue Kobako::Wasm::TimeoutError => e
204
+ raise TimeoutError, "guest exceeded timeout: #{e.message}"
205
+ rescue Kobako::Wasm::MemoryLimitError => e
206
+ raise MemoryLimitError, "guest exceeded memory_limit: #{e.message}"
133
207
  rescue Kobako::Wasm::Error => e
134
208
  raise TrapError, "guest __kobako_run trapped: #{e.message}"
135
209
  end
136
210
 
137
- # Pull the OUTCOME_BUFFER bytes out of guest memory. The +len=0+ case
138
- # is forwarded to {OutcomeDecoder} as an empty String so a single
139
- # boundary attributes every wire-violation outcome
211
+ # Take OUTCOME_BUFFER bytes from guest memory via +Instance#outcome!+
212
+ # and decode them into the Sandbox-level result the unwrapped mruby
213
+ # return value, or a raised three-layer
214
+ # ({SPEC.md "Error Scenarios"}[link:../../SPEC.md]) exception. A zero-
215
+ # length outcome is delivered to +Kobako::Outcome+ as an empty String
216
+ # so a single boundary attributes every wire-violation outcome
140
217
  # ({SPEC.md ABI Signatures}[link:../../SPEC.md]).
141
- def read_outcome_bytes
142
- ptr, len = Kobako::Wasm.unpack_outcome_ptr_len(@instance.take_outcome)
143
- @instance.read_memory(ptr, len)
218
+ #
219
+ # The bang reflects the destructive ext call beneath: the underlying
220
+ # +__kobako_take_outcome+ export invalidates the buffer pointer, so this
221
+ # method must be called at most once per +#run+.
222
+ def take_result!
223
+ Outcome.decode(@instance.outcome!)
144
224
  rescue Kobako::Wasm::Error => e
145
225
  raise TrapError, "failed to read OUTCOME_BUFFER: #{e.message}"
146
226
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/kobako/wasm.rb CHANGED
@@ -5,10 +5,9 @@ module Kobako
5
5
  # (see ext/kobako/src/wasm.rs). This module is the foundational binding
6
6
  # layer for Sandbox (#14), the run path (#16) and RPC dispatch (#18).
7
7
  #
8
- # The classes themselves (Engine / Module / Store / Instance) and the
9
- # error hierarchy (Error / ModuleNotBuiltError) are defined from Rust at
10
- # ext load time; this file only adds the pure-Ruby helpers that have no
11
- # reason to live in Rust.
8
+ # The classes themselves (Instance) and the error hierarchy (Error /
9
+ # ModuleNotBuiltError) are defined from Rust at ext load time; this file
10
+ # only adds the pure-Ruby helpers that have no reason to live in Rust.
12
11
  module Wasm
13
12
  # Absolute path to the gem-bundled `data/kobako.wasm` artifact. Computed
14
13
  # from this file's location so it works for both `bundle exec` (running
@@ -16,20 +15,11 @@ module Kobako
16
15
  #
17
16
  # Returns a String regardless of whether the file currently exists —
18
17
  # call sites that need the file to be present should pass this through
19
- # `Kobako::Wasm::Module.from_file`, which raises `ModuleNotBuiltError`
18
+ # +Kobako::Wasm::Instance.from_path+, which raises +ModuleNotBuiltError+
20
19
  # with a clear remediation message.
21
20
  def self.default_path
22
- File.expand_path("../../data/kobako.wasm", __dir__)
23
- end
24
-
25
- # Unpack the +(ptr << 32) | len+ u64 produced by the Rust ext's
26
- # +__kobako_take_outcome+ export. Returns +[ptr, len]+ as 32-bit
27
- # unsigned integers. Pure-Ruby helper kept near the ABI surface so
28
- # Sandbox does not have to carry bit-level wire layout.
29
- def self.unpack_outcome_ptr_len(packed)
30
- ptr = (packed >> 32) & 0xffff_ffff
31
- len = packed & 0xffff_ffff
32
- [ptr, len]
21
+ dir = __dir__ or raise Error, "Kobako::Wasm.default_path requires __dir__"
22
+ File.expand_path("../../data/kobako.wasm", dir)
33
23
  end
34
24
  end
35
25
  end
data/lib/kobako.rb CHANGED
@@ -3,5 +3,7 @@
3
3
  require_relative "kobako/version"
4
4
  require "kobako/kobako"
5
5
  require_relative "kobako/errors"
6
+ require_relative "kobako/rpc"
7
+ require_relative "kobako/rpc/server"
6
8
  require_relative "kobako/wasm"
7
9
  require_relative "kobako/sandbox"
@@ -0,0 +1,13 @@
1
+ module Kobako
2
+ class Capture
3
+ EMPTY: Capture
4
+
5
+ attr_reader bytes: String
6
+
7
+ def initialize: (bytes: String, truncated: bool) -> void
8
+
9
+ def truncated?: () -> bool
10
+
11
+ def self.from_ext: (String bytes, bool truncated) -> Capture
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ module Kobako
2
+ module Codec
3
+ module Decoder
4
+ def self.decode: (String bytes) -> untyped
5
+
6
+ private
7
+
8
+ def self.validate_utf8!: (untyped value) -> void
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ module Kobako
2
+ module Codec
3
+ module Encoder
4
+ def self.encode: (untyped value) -> String
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ module Kobako
2
+ module Codec
3
+ class Error < StandardError
4
+ end
5
+
6
+ class Truncated < Error
7
+ end
8
+
9
+ class InvalidType < Error
10
+ end
11
+
12
+ class InvalidEncoding < Error
13
+ end
14
+
15
+ class UnsupportedType < Error
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,31 @@
1
+ module Kobako
2
+ module Codec
3
+ class Factory
4
+ include Singleton
5
+ extend Forwardable
6
+ extend SingleForwardable
7
+
8
+ EXT_SYMBOL: Integer
9
+ EXT_HANDLE: Integer
10
+ EXT_ERRENV: Integer
11
+
12
+ def dump: (untyped value) -> String
13
+ def load: (String bytes) -> untyped
14
+
15
+ def self.dump: (untyped value) -> String
16
+ def self.load: (String bytes) -> untyped
17
+
18
+ private
19
+
20
+ def initialize: () -> void
21
+ def register_symbol: () -> void
22
+ def pack_symbol: (Symbol symbol) -> String
23
+ def unpack_symbol: (String payload) -> Symbol
24
+ def register_handle: () -> void
25
+ def register_fault: () -> void
26
+ def unpack_handle: (String payload) -> Kobako::RPC::Handle
27
+ def pack_fault: (Kobako::RPC::Fault fault) -> String
28
+ def unpack_fault: (String payload) -> Kobako::RPC::Fault
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,9 @@
1
+ module Kobako
2
+ module Codec
3
+ module Utils
4
+ def self?.assert_utf8!: (String string, String label) -> void
5
+
6
+ def self?.wire_boundary: [T] () { () -> T } -> T
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,52 @@
1
+ module Kobako
2
+ class Error < StandardError
3
+ end
4
+
5
+ class TrapError < Error
6
+ end
7
+
8
+ class TimeoutError < TrapError
9
+ end
10
+
11
+ class MemoryLimitError < TrapError
12
+ end
13
+
14
+ class SandboxError < Error
15
+ attr_reader origin: String?
16
+ attr_reader klass: String?
17
+ attr_reader backtrace_lines: Array[String]?
18
+ attr_reader details: untyped
19
+
20
+ def initialize: (
21
+ String message,
22
+ ?origin: String?,
23
+ ?klass: String?,
24
+ ?backtrace_lines: Array[String]?,
25
+ ?details: untyped
26
+ ) -> void
27
+ end
28
+
29
+ class ServiceError < Error
30
+ attr_reader origin: String?
31
+ attr_reader klass: String?
32
+ attr_reader backtrace_lines: Array[String]?
33
+ attr_reader details: untyped
34
+
35
+ def initialize: (
36
+ String message,
37
+ ?origin: String?,
38
+ ?klass: String?,
39
+ ?backtrace_lines: Array[String]?,
40
+ ?details: untyped
41
+ ) -> void
42
+
43
+ class Disconnected < ServiceError
44
+ end
45
+ end
46
+
47
+ class HandleTableError < SandboxError
48
+ end
49
+
50
+ class HandleTableExhausted < HandleTableError
51
+ end
52
+ end