kobako 0.3.0 → 0.4.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +85 -5
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +1 -1
  6. data/ext/kobako/src/wasm/cache.rs +12 -4
  7. data/ext/kobako/src/wasm/dispatch.rs +15 -14
  8. data/ext/kobako/src/wasm/host_state.rs +111 -5
  9. data/ext/kobako/src/wasm/instance.rs +135 -33
  10. data/ext/kobako/src/wasm.rs +1 -0
  11. data/lib/kobako/codec/decoder.rb +0 -2
  12. data/lib/kobako/codec/factory.rb +13 -10
  13. data/lib/kobako/codec/utils.rb +105 -13
  14. data/lib/kobako/handle.rb +62 -0
  15. data/lib/kobako/handle_table.rb +119 -0
  16. data/lib/kobako/invocation.rb +56 -25
  17. data/lib/kobako/outcome.rb +42 -12
  18. data/lib/kobako/rpc/dispatcher.rb +22 -20
  19. data/lib/kobako/rpc/envelope.rb +7 -7
  20. data/lib/kobako/rpc/fault.rb +1 -1
  21. data/lib/kobako/rpc/server.rb +12 -24
  22. data/lib/kobako/rpc/wire_error.rb +23 -0
  23. data/lib/kobako/sandbox.rb +77 -24
  24. data/lib/kobako/usage.rb +41 -0
  25. data/lib/kobako/version.rb +1 -1
  26. data/lib/kobako.rb +1 -0
  27. data/sig/kobako/codec/factory.rbs +1 -1
  28. data/sig/kobako/codec/utils.rbs +10 -0
  29. data/sig/kobako/handle.rbs +19 -0
  30. data/sig/kobako/handle_table.rbs +23 -0
  31. data/sig/kobako/invocation.rbs +3 -1
  32. data/sig/kobako/outcome.rbs +1 -1
  33. data/sig/kobako/rpc/dispatcher.rbs +7 -7
  34. data/sig/kobako/rpc/envelope.rbs +3 -3
  35. data/sig/kobako/rpc/server.rbs +1 -7
  36. data/sig/kobako/rpc/wire_error.rbs +6 -0
  37. data/sig/kobako/sandbox.rbs +7 -1
  38. data/sig/kobako/usage.rbs +11 -0
  39. data/sig/kobako/wasm.rbs +2 -0
  40. metadata +9 -5
  41. data/lib/kobako/rpc/handle.rb +0 -39
  42. data/lib/kobako/rpc/handle_table.rb +0 -107
  43. data/sig/kobako/rpc/handle.rbs +0 -19
  44. data/sig/kobako/rpc/handle_table.rbs +0 -25
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "handle"
4
+
5
+ module Kobako
6
+ # Host-side mapping from opaque integer Handle IDs to Ruby objects.
7
+ # The table is owned by +Kobako::Sandbox+ ({docs/behavior.md B-19}[link:../../docs/behavior.md])
8
+ # and injected into the per-Sandbox +Kobako::RPC::Server+ so guest→host
9
+ # RPC dispatch resolves Handle targets and arguments against the same
10
+ # table that host→guest wire encoding allocates into
11
+ # ({docs/behavior.md B-14, B-34}[link:../../docs/behavior.md]).
12
+ #
13
+ # Lifecycle invariants ({docs/behavior.md}[link:../../docs/behavior.md]):
14
+ #
15
+ # - {docs/behavior.md B-15}[link:../../docs/behavior.md] — Handle IDs are
16
+ # allocated by a monotonically increasing counter scoped to a single
17
+ # invocation. The first ID issued in an invocation is 1; ID 0 is reserved
18
+ # as the invalid sentinel and is never returned by +#alloc+.
19
+ #
20
+ # - {docs/behavior.md B-19}[link:../../docs/behavior.md] — At every
21
+ # invocation boundary (via +#reset!+), every Handle issued under the
22
+ # old state becomes invalid. Reset applies uniformly regardless of
23
+ # allocation source (B-14 Service return or B-34 host-injected
24
+ # argument).
25
+ #
26
+ # - {docs/behavior.md B-21}[link:../../docs/behavior.md] — The cap is
27
+ # +0x7fff_ffff+ (2³¹ − 1). Allocation beyond the cap raises immediately
28
+ # — no silent truncation, no wrap, no ID reuse.
29
+ class HandleTable
30
+ # Build a fresh, empty HandleTable. +next_id+ is an internal seam that
31
+ # sets the starting value of the monotonic counter (defaults to 1 per
32
+ # B-15); tests pass a value near +Kobako::Handle::MAX_ID+ to exercise
33
+ # the cap-exhaustion path without 2³¹ allocations.
34
+ def initialize(next_id: 1)
35
+ @entries = {} # : Hash[Integer, untyped]
36
+ @next_id = next_id
37
+ end
38
+
39
+ # Bind +object+ in the table and return a +Kobako::Handle+ token
40
+ # for it. +object+ is any host-side Ruby object to bind. Returns a
41
+ # freshly-allocated +Kobako::Handle+ whose +#id+ falls in
42
+ # +[Kobako::Handle::MIN_ID, Kobako::Handle::MAX_ID]+. Raises
43
+ # +Kobako::HandleTableExhausted+ if the next ID would exceed the
44
+ # cap. The cap is anchored on +Kobako::Handle+ — the wire codec
45
+ # and the allocator share the same invariant
46
+ # ({docs/behavior.md B-21}[link:../../docs/behavior.md]).
47
+ #
48
+ # Returning a Handle (rather than a bare Integer id) keeps the
49
+ # allocator's output a domain entity; +Kobako::Handle.from_wire+
50
+ # is reserved for the codec's wire-decode path, where the id is
51
+ # the only thing the bytes carry.
52
+ def alloc(object)
53
+ id = @next_id
54
+ cap = Kobako::Handle::MAX_ID
55
+ if id > cap
56
+ raise HandleTableExhausted,
57
+ "Handle id space exhausted: allocation would assign id #{id}, exceeding the cap (#{cap})"
58
+ end
59
+
60
+ @entries[id] = object
61
+ @next_id = id + 1
62
+ Kobako::Handle.from_wire(id)
63
+ end
64
+
65
+ # Resolve a Handle ID to its bound object. +id+ is a Handle ID previously
66
+ # returned by +#alloc+. Returns the bound object. Raises
67
+ # +Kobako::HandleTableError+ if +id+ is not currently bound.
68
+ def fetch(id)
69
+ require_bound!(id)
70
+ @entries[id]
71
+ end
72
+
73
+ # Remove and return the binding for +id+. +id+ is the Handle ID to
74
+ # release. Returns the previously-bound object. Raises
75
+ # +Kobako::HandleTableError+ if +id+ is not currently bound.
76
+ def release(id)
77
+ require_bound!(id)
78
+ @entries.delete(id)
79
+ end
80
+
81
+ # Clear all entries AND reset the counter to 1. Called at the per-invocation
82
+ # boundary by +Kobako::Sandbox+ — see
83
+ # {docs/behavior.md B-19}[link:../../docs/behavior.md]. Returns +self+.
84
+ def reset!
85
+ @entries.clear
86
+ @next_id = 1
87
+ self
88
+ end
89
+
90
+ # Mark the entry at +id+ as disconnected (ABA protection). +id+ is the
91
+ # Handle ID to poison; silently ignored if +id+ is not currently bound.
92
+ # Returns +self+ for chainability, matching the convention of +#reset!+.
93
+ def mark_disconnected(id)
94
+ @entries[id] = :disconnected if @entries.key?(id)
95
+ self
96
+ end
97
+
98
+ # Returns the number of currently-bound entries.
99
+ def size
100
+ @entries.size
101
+ end
102
+
103
+ # Returns +true+ when +id+ is currently bound, +false+ otherwise.
104
+ def include?(id)
105
+ @entries.key?(id)
106
+ end
107
+
108
+ private
109
+
110
+ # Single source of truth for the "unknown Handle id" raise shared by
111
+ # {#fetch} and {#release}. Returns +nil+ on success; raises
112
+ # +Kobako::HandleTableError+ when +id+ is not currently bound.
113
+ def require_bound!(id)
114
+ return if @entries.key?(id)
115
+
116
+ raise HandleTableError, "unknown Handle id: #{id.inspect}"
117
+ end
118
+ end
119
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "rpc/handle"
3
+ require_relative "handle"
4
4
  require_relative "codec"
5
5
 
6
6
  module Kobako
@@ -15,13 +15,21 @@ module Kobako
15
15
  # anything that passes +Invocation.new+ is safe to encode and ship to
16
16
  # the guest.
17
17
  #
18
- # Invocation sits at top level, not under +Kobako::RPC+: RPC in SPEC is
19
- # the guest→host capability channel (Server / Client / Request /
20
- # Response / Handle); Invocation is the opposite direction (host→guest
21
- # entrypoint dispatch) and structurally rejects Handles (E-29), so it
22
- # has no relationship with the HandleTable. The +#encode+ output is the
23
- # "Invocation envelope" that ships through the +__kobako_run+ command
24
- # buffer.
18
+ # Invocation sits at top level, not under +Kobako::RPC+: RPC in SPEC
19
+ # is the guest→host capability channel (Server / Client / Request /
20
+ # Response); Invocation is the opposite direction (host→guest
21
+ # entrypoint dispatch). +#encode+ takes the Sandbox's HandleTable
22
+ # and routes any non-wire-representable +args+ / +kwargs+ leaf
23
+ # through it as a +Kobako::Handle+
24
+ # ({docs/behavior.md B-34}[link:../../docs/behavior.md]) — the
25
+ # symmetric counterpart of the guest→host wrap path in
26
+ # +Kobako::RPC::Dispatcher#wrap_as_handle+ (B-14). A
27
+ # +Kobako::Handle+ that arrives **already constructed** in the
28
+ # caller's +args+ / +kwargs+ is rejected at construction (E-29):
29
+ # legitimate Handles only enter Host App code through error fields,
30
+ # so a Handle reaching the call site is by definition smuggled in.
31
+ # The +#encode+ output is the "Invocation envelope" that ships
32
+ # through the +__kobako_run+ command buffer.
25
33
  #
26
34
  # Built on the +class X < Data.define(...)+ subclass form (the
27
35
  # Steep-friendly shape — see +lib/kobako/outcome/panic.rb+).
@@ -46,15 +54,22 @@ module Kobako
46
54
  # Encode this Invocation to the msgpack bytes the guest's
47
55
  # +__kobako_run+ entry point consumes as its command-buffer payload
48
56
  # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]).
49
- # The Value Object's own invariants are the contract; this method
50
- # does not re-check the shape. Layout: msgpack map with string keys
51
- # +"entrypoint"+ (Symbol via ext 0x00), +"args"+ (Array), +"kwargs"+
52
- # (Map with Symbol keys).
53
- def encode
57
+ # Walks +args+ / +kwargs+ through {Codec::Utils.deep_wrap} so any
58
+ # non-wire-representable leaf is allocated into +handle_table+ and
59
+ # replaced with a +Kobako::Handle+
60
+ # ({docs/behavior.md B-34}[link:../../docs/behavior.md]); the
61
+ # +handle_table+ argument is the Sandbox's table, sharing the same
62
+ # allocator the guest→host return path (B-14) uses.
63
+ #
64
+ # Layout: msgpack map with string keys +"entrypoint"+ (Symbol via
65
+ # ext 0x00), +"args"+ (Array), +"kwargs"+ (Map with Symbol keys);
66
+ # any wrapped leaf rides as ext 0x01 in its original position
67
+ # (docs/wire-codec.md § ext 0x01 position rules).
68
+ def encode(handle_table)
54
69
  Codec::Encoder.encode(
55
70
  "entrypoint" => entrypoint,
56
- "args" => args,
57
- "kwargs" => kwargs
71
+ "args" => Codec::Utils.deep_wrap(args, handle_table),
72
+ "kwargs" => Codec::Utils.deep_wrap(kwargs, handle_table)
58
73
  )
59
74
  end
60
75
 
@@ -80,22 +95,25 @@ module Kobako
80
95
  target_str.to_sym
81
96
  end
82
97
 
83
- # E-29: +args+ must not contain a +Kobako::RPC::Handle+. Handles
84
- # are per-invocation and cannot enter the next invocation through
85
- # a control-plane channel; a guest that needs to call into a
86
- # stateful host object must obtain a fresh Handle through a
87
- # Service RPC inside the dispatched entrypoint.
98
+ # E-29: +args+ must not contain a +Kobako::Handle+. The Handle
99
+ # allocator lives inside the Host Gem; legitimate paths surface
100
+ # Handle objects only through raised error fields, so a Handle
101
+ # reaching +args+ is a forged or smuggled token. Non-wire-
102
+ # representable arguments that are not Handles are handled by
103
+ # auto-wrap inside +#encode+ (B-34) — the reject path is reserved
104
+ # for Handle objects specifically.
88
105
  def validate_args!(args)
89
106
  raise ArgumentError, "Invocation args must be Array" unless args.is_a?(Array)
90
- raise ArgumentError, "Invocation args must not contain a Kobako::RPC::Handle" if args.any?(Kobako::RPC::Handle)
107
+ raise ArgumentError, forged_handle_message("args") if args.any?(Kobako::Handle)
91
108
 
92
109
  args
93
110
  end
94
111
 
95
- # E-30: +kwargs+ keys must be Symbols, mirroring the wire codec's
96
- # Request kwargs rule. Validation lives here (not in the codec) so
97
- # the Host App sees the host-side error message before any encode
98
- # / decode boundary.
112
+ # E-30 covers the non-Symbol kwargs-key case; E-29 also rejects a
113
+ # +Kobako::Handle+ arriving as a kwargs value (same forged-token
114
+ # principle as the +args+ branch). Both checks live here so the
115
+ # Host App sees the host-side error message before any encode /
116
+ # decode boundary.
99
117
  def validate_kwargs!(kwargs)
100
118
  raise ArgumentError, "Invocation kwargs must be Hash" unless kwargs.is_a?(Hash)
101
119
 
@@ -104,9 +122,22 @@ module Kobako
104
122
  raise ArgumentError,
105
123
  "Invocation kwargs keys must be Symbols (got #{bad_keys.inspect})"
106
124
  end
125
+ raise ArgumentError, forged_handle_message("kwargs values") if kwargs.each_value.any?(Kobako::Handle)
107
126
 
108
127
  kwargs
109
128
  end
129
+
130
+ # Single source of truth for the E-29 reject message so the args
131
+ # and kwargs branches stay phrased identically. Message stays in
132
+ # caller vocabulary: it names the affected slot and the reason
133
+ # without leaking SPEC anchor identifiers (B-xx / E-xx live in
134
+ # source comments, not user-visible errors) or self-referential
135
+ # architecture terms — the error is raised BY kobako, so saying
136
+ # "allocated by the Host Gem" reads as third-person about self.
137
+ def forged_handle_message(slot)
138
+ "Invocation #{slot} must not contain a Kobako::Handle — " \
139
+ "Kobako::Handle instances are internal wire tokens, not caller-constructible"
140
+ end
110
141
  # steep:ignore:end
111
142
  end
112
143
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "outcome/panic"
4
+ require_relative "rpc/wire_error"
4
5
 
5
6
  module Kobako
6
7
  # Host-facing boundary for the OUTCOME_BUFFER produced by
@@ -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[:wire_error]+ 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_wire_violation_error(
84
+ "Sandbox produced an invalid result value",
85
+ wire_error: e.message
86
+ )
71
87
  end
72
88
 
73
89
  # Decode failure on the panic tag is a SandboxError (E-08). Either
@@ -77,16 +93,21 @@ module Kobako
77
93
  def decode_panic(body)
78
94
  raise build_panic_error(parse_panic(body))
79
95
  rescue Kobako::Codec::Error => e
80
- raise build_wire_violation_error("panic envelope decode failed: #{e.message}")
96
+ raise build_wire_violation_error(
97
+ "Sandbox produced an invalid panic record",
98
+ wire_error: e.message
99
+ )
81
100
  end
82
101
 
83
102
  # Build a +Panic+ value object from the msgpack-decoded body. Raises
84
103
  # +Kobako::Codec::InvalidType+ when the body is not a map or
85
104
  # when required keys are missing — both routed by +decode_panic+ to
86
- # +build_wire_violation_error+.
105
+ # +build_wire_violation_error+. The +InvalidType+ message itself is
106
+ # never user-facing; it lands in +details[:wire_error]+ via the
107
+ # rescue chain above.
87
108
  def parse_panic(body)
88
109
  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)
110
+ raise Kobako::Codec::InvalidType, "panic body must be a map, got #{map.class}" unless map.is_a?(Hash)
90
111
 
91
112
  Kobako::Codec::Utils.wire_boundary do
92
113
  Panic.new(
@@ -129,11 +150,20 @@ module Kobako
129
150
  end
130
151
  end
131
152
 
132
- def build_wire_violation_error(message)
133
- SandboxError.new(
153
+ # Lift the wire-violation fallback to the real
154
+ # +Kobako::RPC::WireError+ class so callers can +rescue+ it
155
+ # specifically instead of pattern-matching on +error.klass+. The
156
+ # +klass+ field is still populated so existing operator-side
157
+ # tooling that greps on the string continues to work.
158
+ # +wire_error+ carries the inner codec / framing message for
159
+ # operator diagnosis without polluting the user-facing
160
+ # +#message+.
161
+ def build_wire_violation_error(message, wire_error: nil)
162
+ Kobako::RPC::WireError.new(
134
163
  message,
135
164
  origin: Panic::ORIGIN_SANDBOX,
136
- klass: "Kobako::RPC::WireError"
165
+ klass: "Kobako::RPC::WireError",
166
+ details: wire_error.nil? ? nil : { wire_error: wire_error }
137
167
  )
138
168
  end
139
169
  end
@@ -34,22 +34,23 @@ module Kobako
34
34
  # Dispatch a single RPC request and return the encoded response bytes.
35
35
  # Called by +Kobako::RPC::Server#dispatch+ which is invoked from the
36
36
  # Rust ext inside +__kobako_dispatch+. +request_bytes+ is the
37
- # msgpack-encoded Request envelope. +server+ is the live Server for
38
- # this run, used to resolve path-based targets via +#lookup+ and to
39
- # access the +#handle_table+ for Handle-based targets and return-value
40
- # wrapping. Always returns a binary Stringnever raises. Any failure
41
- # during decode, lookup, or method invocation is reified as a
42
- # Response.error envelope so the guest sees the failure as a normal RPC
43
- # error rather than a wasm trap
37
+ # msgpack-encoded Request envelope. +server+ resolves path-based
38
+ # Member targets via +#lookup+. +handle_table+ is the Sandbox's
39
+ # HandleTable, injected separately so Dispatcher does not depend
40
+ # on Server publishing a Handle accessorHandle is a
41
+ # Sandbox-level domain entity (B-19) and the dispatcher is its
42
+ # only consumer here. Always returns a binary String never
43
+ # raises. Any failure during decode, lookup, or method invocation
44
+ # is reified as a Response.error envelope so the guest sees the
45
+ # failure as a normal RPC error rather than a wasm trap
44
46
  # ({docs/behavior.md B-12}[link:../../../docs/behavior.md]).
45
- def dispatch(request_bytes, server)
47
+ def dispatch(request_bytes, server, handle_table)
46
48
  request = Kobako::RPC.decode_request(request_bytes)
47
- handle_table = server.handle_table
48
49
  target = resolve_target(request.target, server, handle_table)
49
50
  args = request.args.map { |v| resolve_arg(v, handle_table) }
50
51
  kwargs = request.kwargs.transform_values { |v| resolve_arg(v, handle_table) }
51
52
  value = invoke(target, request.method_name, args, kwargs)
52
- encode_ok(value, server)
53
+ encode_ok(value, handle_table)
53
54
  rescue StandardError => e
54
55
  encode_caught_error(e)
55
56
  end
@@ -58,14 +59,15 @@ module Kobako
58
59
  # envelope. +error+ is the +StandardError+ caught by {#dispatch}'s
59
60
  # rescue. Returns a msgpack-encoded Response envelope (binary). Four
60
61
  # error buckets ({docs/behavior.md B-12}[link:../../../docs/behavior.md]):
61
- # +Kobako::Codec::Error+ → type="runtime" (wire decode failed);
62
+ # +Kobako::Codec::Error+ → type="runtime" (malformed RPC request);
62
63
  # +DisconnectedTargetError+ → type="disconnected" (E-14);
63
64
  # +UndefinedTargetError+ → type="undefined" (E-13); +ArgumentError+ →
64
65
  # type="argument" (B-12 arity mismatch); everything else →
65
66
  # type="runtime".
66
67
  def encode_caught_error(error)
67
68
  case error
68
- when Kobako::Codec::Error then encode_error("runtime", "wire decode failed: #{error.message}")
69
+ when Kobako::Codec::Error then encode_error("runtime",
70
+ "Sandbox received a malformed RPC request: #{error.message}")
69
71
  when DisconnectedTargetError then encode_error("disconnected", error.message)
70
72
  when UndefinedTargetError then encode_error("undefined", error.message)
71
73
  when ArgumentError then encode_error("argument", error.message)
@@ -86,7 +88,7 @@ module Kobako
86
88
  end
87
89
  end
88
90
 
89
- # {docs/behavior.md B-16}[link:../../../docs/behavior.md] — An RPC::Handle arriving as a positional or keyword
91
+ # {docs/behavior.md B-16}[link:../../../docs/behavior.md] — An Kobako::Handle arriving as a positional or keyword
90
92
  # argument identifies a host-side object previously allocated by a prior
91
93
  # RPC's Handle wrap (B-14). Resolve it back to the Ruby object before
92
94
  # the dispatch reaches +public_send+. A Handle whose entry is the
@@ -94,7 +96,7 @@ module Kobako
94
96
  # the dispatcher emits a Response.error with type="disconnected".
95
97
  def resolve_arg(value, handle_table)
96
98
  case value
97
- when Kobako::RPC::Handle
99
+ when Kobako::Handle
98
100
  require_live_object!(value.id, handle_table)
99
101
  else
100
102
  value
@@ -112,7 +114,7 @@ module Kobako
112
114
  case target
113
115
  when String
114
116
  resolve_path(target, server)
115
- when Kobako::RPC::Handle
117
+ when Kobako::Handle
116
118
  resolve_handle(target, handle_table)
117
119
  end
118
120
  end
@@ -144,19 +146,19 @@ module Kobako
144
146
  # HandleTable via {#wrap_as_handle} and re-encodes with the Capability
145
147
  # Handle in place ({docs/behavior.md B-14}[link:../../../docs/behavior.md]). The happy
146
148
  # path encodes exactly once.
147
- def encode_ok(value, server)
149
+ def encode_ok(value, handle_table)
148
150
  response = Kobako::RPC::Response.ok(value)
149
151
  Kobako::RPC.encode_response(response)
150
152
  rescue Kobako::Codec::UnsupportedType
151
- encode_ok(wrap_as_handle(value, server), server)
153
+ encode_ok(wrap_as_handle(value, handle_table), handle_table)
152
154
  end
153
155
 
154
- # Allocate +value+ in the Server's HandleTable and return a +Handle+
156
+ # Allocate +value+ in the Sandbox's HandleTable and return a +Handle+
155
157
  # that the wire codec can carry ({docs/behavior.md B-14}[link:../../../docs/behavior.md]).
156
158
  # Used as the fallback path of {#encode_ok} when +value+ has no wire
157
159
  # representation.
158
- def wrap_as_handle(value, server)
159
- Kobako::RPC::Handle.new(server.handle_table.alloc(value))
160
+ def wrap_as_handle(value, handle_table)
161
+ handle_table.alloc(value)
160
162
  end
161
163
 
162
164
  def encode_error(type, message)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "handle"
3
+ require_relative "../handle"
4
4
  require_relative "fault"
5
5
  require_relative "../codec"
6
6
 
@@ -25,8 +25,8 @@ module Kobako
25
25
  Request = Data.define(:target, :method_name, :args, :kwargs) do
26
26
  # steep:ignore:start
27
27
  def initialize(target:, method:, args: [], kwargs: {})
28
- unless target.is_a?(String) || target.is_a?(Kobako::RPC::Handle)
29
- raise ArgumentError, "Request target must be String or Kobako::RPC::Handle, got #{target.class}"
28
+ unless target.is_a?(String) || target.is_a?(Kobako::Handle)
29
+ raise ArgumentError, "Request target must be String or Kobako::Handle, got #{target.class}"
30
30
  end
31
31
  raise ArgumentError, "Request method must be String" unless method.is_a?(String)
32
32
  raise ArgumentError, "Request args must be Array" unless args.is_a?(Array)
@@ -56,7 +56,7 @@ module Kobako
56
56
  def self.decode_request(bytes)
57
57
  arr = Codec::Decoder.decode(bytes)
58
58
  unless arr.is_a?(Array) && arr.length == 4
59
- raise Codec::InvalidType, "Request must be a 4-element array, got #{arr.inspect}"
59
+ raise Codec::InvalidType, "Request envelope is malformed (expected a 4-element array)"
60
60
  end
61
61
 
62
62
  target, method_name, args, kwargs = arr
@@ -87,10 +87,10 @@ module Kobako
87
87
 
88
88
  def initialize(status:, payload:)
89
89
  unless [STATUS_OK, STATUS_ERROR].include?(status)
90
- raise ArgumentError, "Response status must be 0 or 1, got #{status.inspect}"
90
+ raise ArgumentError, "Response status must be 0 (ok) or 1 (error), got #{status.inspect}"
91
91
  end
92
92
  if status == STATUS_ERROR && !payload.is_a?(Kobako::RPC::Fault)
93
- raise ArgumentError, "Response status=1 payload must be Kobako::RPC::Fault"
93
+ raise ArgumentError, "Response with error status must carry a Kobako::RPC::Fault payload"
94
94
  end
95
95
 
96
96
  super
@@ -108,7 +108,7 @@ module Kobako
108
108
  def self.decode_response(bytes)
109
109
  arr = Codec::Decoder.decode(bytes)
110
110
  unless arr.is_a?(Array) && arr.length == 2
111
- raise Codec::InvalidType, "Response must be a 2-element array, got #{arr.inspect}"
111
+ raise Codec::InvalidType, "Response envelope is malformed (expected a 2-element array)"
112
112
  end
113
113
 
114
114
  status, payload = arr
@@ -23,7 +23,7 @@ module Kobako
23
23
  # Reach it through +self.class::VALID_TYPES+ — Data.define's block
24
24
  # scope resolves bare constants against the enclosing +Wire+ module,
25
25
  # so a bare +VALID_TYPES+ would raise +NameError+. Same pattern as
26
- # +RPC::Handle+.
26
+ # +Kobako::Handle+.
27
27
  # steep:ignore:start
28
28
  def initialize(type:, message:, details: nil)
29
29
  valid_types = self.class::VALID_TYPES
@@ -4,7 +4,7 @@ require "msgpack"
4
4
  require_relative "../errors"
5
5
  require_relative "envelope"
6
6
  require_relative "namespace"
7
- require_relative "handle_table"
7
+ require_relative "../handle_table"
8
8
  require_relative "dispatcher"
9
9
 
10
10
  module Kobako
@@ -24,10 +24,11 @@ module Kobako
24
24
  #
25
25
  # Namespaces live at +Kobako::RPC::Namespace+
26
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).
27
+ # +Kobako::HandleTable+ (lib/kobako/handle_table.rb) and is owned by
28
+ # the Sandbox the Server only holds an injected reference so RPC
29
+ # dispatch resolves against the same table the wire layer allocates
30
+ # into (docs/behavior.md B-19). Dispatch helpers live at
31
+ # +Kobako::RPC::Dispatcher+ (lib/kobako/rpc/dispatcher.rb).
31
32
  class Server
32
33
  # Build a fresh Server. +handle_table+ is an internal seam that
33
34
  # injects a pre-configured +HandleTable+; tests pass one whose +next_id+
@@ -76,11 +77,6 @@ module Kobako
76
77
  !namespace.nil? && !member_name.nil? && !namespace[member_name].nil?
77
78
  end
78
79
 
79
- # Returns all declared +Kobako::RPC::Namespace+ instances as an +Array+.
80
- def namespaces
81
- @namespaces.values
82
- end
83
-
84
80
  # Returns the number of declared namespaces as an +Integer+.
85
81
  def size
86
82
  @namespaces.size
@@ -122,27 +118,19 @@ module Kobako
122
118
  @sealed
123
119
  end
124
120
 
125
- # Reset the HandleTable for a new invocation boundary. Called by
126
- # +Sandbox+ before each invocation
127
- # ({docs/behavior.md B-19}[link:../../../docs/behavior.md]).
128
- def reset_handles!
129
- @handle_table.reset!
130
- end
131
-
132
121
  # Dispatch a single RPC request and return the encoded response bytes
133
122
  # ({docs/behavior.md B-12}[link:../../../docs/behavior.md]). +request_bytes+ is a
134
123
  # msgpack-encoded Request envelope. Called by the Rust ext from inside
135
124
  # +__kobako_dispatch+. Always returns a binary +String+ — never raises.
136
- # Delegates to +Dispatcher.dispatch+ which reifies any failure as a
137
- # +Response.error+ envelope so the guest sees the failure as a normal RPC
138
- # error rather than a wasm trap.
125
+ # Forwards both the Server (for namespace lookup) and the injected
126
+ # +HandleTable+ (for Handle resolution / return-value wrapping) to
127
+ # +Dispatcher.dispatch+. The Server holds the HandleTable as an
128
+ # injected reference, not an owned resource — the Sandbox owns it
129
+ # (B-19) — so the table is not exposed via accessors.
139
130
  def dispatch(request_bytes)
140
- Dispatcher.dispatch(request_bytes, self)
131
+ Dispatcher.dispatch(request_bytes, self, @handle_table)
141
132
  end
142
133
 
143
- # Expose the +HandleTable+ for tests and wire-layer Handle wrapping.
144
- attr_reader :handle_table
145
-
146
134
  private
147
135
 
148
136
  # Split +target+ on the +::+ separator and resolve the namespace half.
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Kobako
6
+ module RPC
7
+ # +Kobako::SandboxError+ subclass raised when the host detects a
8
+ # structural violation of the wire contract while decoding bytes
9
+ # produced by the guest (a malformed Outcome envelope, a result body
10
+ # that fails msgpack decode, a Panic envelope missing required
11
+ # fields). Distinct from a Wasm trap (engine signalled the guest
12
+ # runtime is unrecoverable) and from a normal sandbox-layer failure
13
+ # (the script raised but the protocol was respected): a +WireError+
14
+ # always indicates the guest runtime is corrupted — the only safe
15
+ # recovery is to discard the Sandbox and start a new invocation.
16
+ #
17
+ # Inherits from +Kobako::SandboxError+ so a single
18
+ # +rescue Kobako::SandboxError+ still catches it; callers that want
19
+ # to distinguish wire-violation paths from script failures can
20
+ # +rescue Kobako::RPC::WireError+ directly.
21
+ class WireError < Kobako::SandboxError; end
22
+ end
23
+ end