kobako 0.13.0 → 0.15.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +81 -0
  4. data/Cargo.lock +3 -3
  5. data/README.md +107 -32
  6. data/ROADMAP.md +25 -0
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +14 -0
  9. data/crates/kobako-runtime/Cargo.toml +1 -1
  10. data/crates/kobako-runtime/README.md +1 -1
  11. data/crates/kobako-runtime/src/error.rs +8 -0
  12. data/crates/kobako-runtime/src/snapshot.rs +7 -3
  13. data/crates/kobako-wasmtime/CHANGELOG.md +14 -0
  14. data/crates/kobako-wasmtime/Cargo.toml +2 -2
  15. data/crates/kobako-wasmtime/README.md +1 -1
  16. data/crates/kobako-wasmtime/src/capture.rs +17 -13
  17. data/crates/kobako-wasmtime/src/driver.rs +7 -8
  18. data/crates/kobako-wasmtime/src/frames.rs +3 -10
  19. data/crates/kobako-wasmtime/src/guest_mem.rs +8 -1
  20. data/crates/kobako-wasmtime/src/instance_pre.rs +7 -6
  21. data/crates/kobako-wasmtime/src/invocation.rs +4 -5
  22. data/crates/kobako-wasmtime/src/trap.rs +65 -71
  23. data/data/kobako.wasm +0 -0
  24. data/ext/kobako/Cargo.toml +1 -1
  25. data/ext/kobako/src/runtime/errors.rs +9 -39
  26. data/ext/kobako/src/runtime.rs +5 -20
  27. data/lib/kobako/catalog/handles.rb +3 -3
  28. data/lib/kobako/catalog/services.rb +123 -0
  29. data/lib/kobako/catalog/snippets.rb +2 -2
  30. data/lib/kobako/catalog.rb +2 -2
  31. data/lib/kobako/codec/decoder.rb +10 -10
  32. data/lib/kobako/codec/encoder.rb +5 -5
  33. data/lib/kobako/codec/error.rb +3 -3
  34. data/lib/kobako/codec/ext_types.rb +169 -0
  35. data/lib/kobako/codec/handle_walk.rb +17 -21
  36. data/lib/kobako/codec/state.rb +98 -0
  37. data/lib/kobako/codec/utils.rb +9 -9
  38. data/lib/kobako/codec.rb +23 -6
  39. data/lib/kobako/errors.rb +14 -39
  40. data/lib/kobako/fault.rb +1 -1
  41. data/lib/kobako/handle.rb +6 -3
  42. data/lib/kobako/outcome.rb +14 -7
  43. data/lib/kobako/pool.rb +1 -1
  44. data/lib/kobako/sandbox.rb +30 -25
  45. data/lib/kobako/transport/dispatcher.rb +41 -31
  46. data/lib/kobako/transport/request.rb +16 -11
  47. data/lib/kobako/transport/response.rb +11 -4
  48. data/lib/kobako/transport/run.rb +1 -1
  49. data/lib/kobako/transport/yield.rb +7 -4
  50. data/lib/kobako/transport/yielder.rb +20 -12
  51. data/lib/kobako/version.rb +1 -1
  52. data/release-please-config.json +37 -7
  53. data/sig/kobako/catalog/services.rbs +25 -0
  54. data/sig/kobako/codec/ext_types.rbs +31 -0
  55. data/sig/kobako/codec/handle_walk.rbs +2 -2
  56. data/sig/kobako/codec/state.rbs +20 -0
  57. data/sig/kobako/codec.rbs +14 -0
  58. data/sig/kobako/handle.rbs +0 -2
  59. data/sig/kobako/sandbox.rbs +1 -1
  60. data/sig/kobako/transport/dispatcher.rbs +8 -8
  61. data/sig/kobako/transport/run.rbs +1 -1
  62. data/sig/kobako/transport/yielder.rbs +3 -3
  63. data/sig/kobako/transport.rbs +8 -0
  64. metadata +8 -7
  65. data/lib/kobako/catalog/namespaces.rb +0 -115
  66. data/lib/kobako/codec/factory.rb +0 -187
  67. data/lib/kobako/namespace.rb +0 -78
  68. data/sig/kobako/catalog/namespaces.rbs +0 -17
  69. data/sig/kobako/codec/factory.rbs +0 -34
  70. data/sig/kobako/namespace.rbs +0 -21
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "handles"
4
+ require_relative "../codec"
5
+ require_relative "../errors"
6
+
7
+ module Kobako
8
+ module Catalog
9
+ # Kobako::Catalog::Services — per-Sandbox registry of Service
10
+ # bindings keyed by their constant-path name. Holds the flat
11
+ # path→object table and the preamble emitted on Frame 1.
12
+ #
13
+ # Public API:
14
+ #
15
+ # services = Kobako::Catalog::Services.new
16
+ # services.bind("MyService::KV", kv_object) # => services (chainable)
17
+ # services.encode # => msgpack bytes for Frame 1
18
+ # services.lookup("MyService::KV") # => kv_object
19
+ #
20
+ # Per-dispatch routing is +Kobako::Transport::Dispatcher+'s
21
+ # responsibility — the Dispatcher receives this registry and the
22
+ # +Catalog::Handles+ as arguments from the +Runtime#on_dispatch+ Proc
23
+ # that +Kobako::Sandbox#initialize+ installs. The registry holds an
24
+ # injected +Catalog::Handles+ reference so dispatch target resolution
25
+ # and host→guest auto-wrap share the same Sandbox-owned allocator.
26
+ class Services
27
+ # Ruby constant-name pattern each +::+-separated bind-path segment
28
+ # must match.
29
+ NAME_PATTERN = /\A[A-Z]\w*\z/
30
+
31
+ # Build a fresh registry. +handler+ is an internal seam that injects
32
+ # a pre-configured +Catalog::Handles+; tests pass one whose +next_id+
33
+ # is pinned near +MAX_ID+ to exercise the cap-exhaustion path
34
+ # without 2³¹ allocations. Production callers leave it at the default.
35
+ def initialize(handler: Catalog::Handles.new)
36
+ @bindings = {} # : Hash[String, untyped]
37
+ @handler = handler
38
+ @sealed = false
39
+ @encoded = nil # : String?
40
+ end
41
+
42
+ # Bind +object+ as the Service reachable at +path+ — a +Symbol+ or
43
+ # +String+ of one or more +::+-separated constant-form segments
44
+ # (+"MyService::KV"+ or a top-level +"File"+). Returns +self+ for
45
+ # chaining. Raises +ArgumentError+ when a segment is malformed, when
46
+ # +path+ collides with an existing binding (a name is a bound Service
47
+ # or a grouping prefix, never both), or when the owning Sandbox has
48
+ # been sealed by its first invocation.
49
+ def bind(path, object)
50
+ raise ArgumentError, "cannot bind after first Sandbox invocation" if @sealed
51
+
52
+ path_str = validate_path!(path)
53
+ raise ArgumentError, "Service path #{path_str} conflicts with an existing binding" if collision?(path_str)
54
+
55
+ @bindings[path_str] = object
56
+ self
57
+ end
58
+
59
+ # Resolve a +target+ constant path to the bound Service. Raises
60
+ # +KeyError+ when no Service is bound at +target+.
61
+ def lookup(target)
62
+ target_str = target.to_s
63
+ raise KeyError, "no service bound at #{target_str.inspect}" unless @bindings.key?(target_str)
64
+
65
+ @bindings[target_str]
66
+ end
67
+
68
+ # Encode the preamble as msgpack bytes for stdin Frame 1 delivery —
69
+ # a flat array of the bound constant paths, in bind order:
70
+ # +["MyService::KV", "File"]+. Routes through Kobako::Codec::Encoder
71
+ # like every other host-side wire encode; the preamble carries only
72
+ # Strings, so none of the kobako ext types fire. Returns a binary
73
+ # +String+ of msgpack bytes.
74
+ #
75
+ # Once sealed, the bytes are computed once and reused for every
76
+ # subsequent invocation: sealing freezes Service registration at the
77
+ # first invocation, so a bind reaching the registry after the seal
78
+ # raises +ArgumentError+ and never alters Frame 1.
79
+ def encode
80
+ return @encoded if @encoded
81
+
82
+ bytes = Codec::Encoder.encode(@bindings.keys).freeze
83
+ @encoded = bytes if @sealed
84
+ bytes
85
+ end
86
+
87
+ # Mark the registry as sealed. Called by +Sandbox+ on the first
88
+ # invocation; afterwards #bind raises ArgumentError. Idempotent;
89
+ # returns +self+.
90
+ def seal!
91
+ @sealed = true
92
+ self
93
+ end
94
+
95
+ # Returns +true+ when #seal! has been called, +false+ otherwise.
96
+ def sealed?
97
+ @sealed
98
+ end
99
+
100
+ private
101
+
102
+ def validate_path!(path)
103
+ path_str = path.to_s
104
+ segments = path_str.split("::", -1)
105
+ return path_str if !segments.empty? && segments.all? { |seg| NAME_PATTERN.match?(seg) }
106
+
107
+ raise ArgumentError,
108
+ "bind path must be constant-form segments joined by '::' (got #{path.inspect})"
109
+ end
110
+
111
+ # A path collides when it equals, is a prefix of, or extends an
112
+ # existing binding on the +::+ segment boundary — the guardrail that
113
+ # keeps a name from being both a bound Service and a grouping prefix.
114
+ def collision?(path)
115
+ @bindings.each_key.any? do |existing|
116
+ existing == path ||
117
+ existing.start_with?("#{path}::") ||
118
+ path.start_with?("#{existing}::")
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -43,8 +43,8 @@ module Kobako
43
43
  #
44
44
  # The bytes are memoized — the table is replayed verbatim on every
45
45
  # invocation after sealing, so Frame 3 never changes between
46
- # encodes; {#register} drops the memo while the table is still open.
47
- # Unlike +Catalog::Namespaces#encode+, which gates its memo on the
46
+ # encodes; #register drops the memo while the table is still open.
47
+ # Unlike +Catalog::Services#encode+, which gates its memo on the
48
48
  # seal, this one can fill eagerly and invalidate in +#register+
49
49
  # because every mutation funnels through that single method — there is
50
50
  # no out-of-sight child object to change the result behind its back.
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "catalog/handles"
4
- require_relative "catalog/namespaces"
4
+ require_relative "catalog/services"
5
5
  require_relative "catalog/snippets"
6
6
 
7
7
  module Kobako
8
8
  # Kobako::Catalog — Sandbox-level configuration and per-invocation
9
9
  # allocation tables. Houses the three host-side registries the Sandbox
10
- # owns: +Catalog::Namespaces+ (Namespace / Member registry),
10
+ # owns: +Catalog::Services+ (path→Service binding registry),
11
11
  # +Catalog::Snippets+ (preloaded source / bytecode entries), and
12
12
  # +Catalog::Handles+ (per-invocation Handle ID allocator).
13
13
  #
@@ -3,7 +3,7 @@
3
3
  require "msgpack"
4
4
 
5
5
  require_relative "error"
6
- require_relative "factory"
6
+ require_relative "ext_types"
7
7
  require_relative "utils"
8
8
 
9
9
  module Kobako
@@ -12,32 +12,32 @@ module Kobako
12
12
  # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type Mapping).
13
13
  #
14
14
  # Translates msgpack gem exceptions into the kobako error taxonomy
15
- # ({Truncated}, {InvalidType}, {InvalidEncoding}, {UnsupportedType}) so
15
+ # (Truncated, InvalidType, InvalidEncoding, UnsupportedType) so
16
16
  # callers can pattern-match on the SPEC's wire-violation categories
17
17
  # without leaking the gem's internal exception classes.
18
18
  #
19
- # Public API is a single function — {.decode}. The decoder is
19
+ # Public API is a single function — +.decode+. The decoder is
20
20
  # stateless; the +MessagePack::Unpacker+ instance is built per call
21
21
  # because callers always decode exactly one wire value at a time.
22
22
  module Decoder
23
23
  # Decode +bytes+ into one Ruby value and validate transitively
24
- # against the SPEC type mapping. Raises {Truncated}, {InvalidType},
25
- # or {InvalidEncoding} on wire violations.
24
+ # against the SPEC type mapping. Raises Truncated, InvalidType,
25
+ # or InvalidEncoding on wire violations.
26
26
  #
27
27
  # When a block is given, the decoded value is yielded and the block's
28
28
  # result is returned — wire Value Objects use this to build themselves
29
29
  # from the decoded payload. The block runs inside this method's
30
30
  # rescue, so a Value Object's +ArgumentError+ invariant failure
31
- # surfaces as {InvalidType} without a separate {Utils.with_boundary}
31
+ # surfaces as InvalidType without a separate Utils.with_boundary
32
32
  # wrapper at the call site.
33
33
  def self.decode(bytes)
34
- value = Factory.load(bytes.b)
34
+ value = FACTORY.load(bytes.b)
35
35
  validate_utf8!(value)
36
36
  block_given? ? yield(value) : value
37
37
  # msgpack gem raises the format/type errors below; +ArgumentError+
38
38
  # comes from our ext-type validators (Handle id range, Exception type
39
39
  # whitelist) and from a yielded block's Value Object invariants — both
40
- # are wire violations, so both map to {InvalidType}.
40
+ # are wire violations, so both map to InvalidType.
41
41
  rescue ::MessagePack::UnknownExtTypeError, ::MessagePack::MalformedFormatError,
42
42
  ::MessagePack::StackError, ::ArgumentError => e
43
43
  raise InvalidType, e.message
@@ -53,8 +53,8 @@ module Kobako
53
53
  # Encoding Rules). The msgpack gem returns UTF-8-tagged Strings for
54
54
  # str family but does not validate the bytes; +bin+ family decodes
55
55
  # to ASCII-8BIT. Walk the tree once and reject invalid UTF-8 in any
56
- # str-typed leaf via {Utils.assert_utf8!}. {Kobako::Fault}
57
- # payloads are validated transitively: +Factory.unpack_fault+
56
+ # str-typed leaf via Utils.assert_utf8!. Kobako::Fault
57
+ # payloads are validated transitively: +ExtTypes#unpack_fault+
58
58
  # feeds the inner ext-0x02 bytes back through this Decoder, so their
59
59
  # +str+ fields are already covered by the time control returns here.
60
60
  class << self
@@ -3,7 +3,7 @@
3
3
  require "msgpack"
4
4
 
5
5
  require_relative "error"
6
- require_relative "factory"
6
+ require_relative "ext_types"
7
7
 
8
8
  module Kobako
9
9
  module Codec
@@ -13,10 +13,10 @@ module Kobako
13
13
  # The codec backbone is the official +msgpack+ gem: integers, floats,
14
14
  # strings, arrays, and maps go through the gem's narrowest-encoding
15
15
  # logic; the three kobako-specific ext types (0x00 Symbol, 0x01
16
- # Capability Handle, 0x02 Exception envelope) are registered on
17
- # the cached {Kobako::Codec::Factory} singleton.
16
+ # Capability Handle, 0x02 Exception envelope) are registered by
17
+ # ExtTypes on the process-wide factory.
18
18
  #
19
- # Public API is a single function — {.encode}. The codec is stateless;
19
+ # Public API is a single function — +.encode+. The codec is stateless;
20
20
  # there is no buffer accumulator and no streaming write API. Callers
21
21
  # that need to concatenate multiple encodings build the bytes
22
22
  # themselves.
@@ -32,7 +32,7 @@ module Kobako
32
32
  # +NoMethodError+ is likewise reported as +UnsupportedType+ rather than
33
33
  # propagating.
34
34
  def self.encode(value)
35
- Factory.dump(value)
35
+ FACTORY.dump(value)
36
36
  rescue ::RangeError, ::NoMethodError => e
37
37
  raise UnsupportedType, e.message
38
38
  end
@@ -7,11 +7,11 @@ module Kobako
7
7
  # The wire codec implements the binary contract pinned in
8
8
  # {docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type Mapping.
9
9
  # Every wire violation surfaces as a
10
- # subclass of {Error} so callers can pattern-match on the specific
10
+ # subclass of Error so callers can pattern-match on the specific
11
11
  # fault while still rescuing all codec faults via this base class.
12
12
  #
13
13
  # Higher layers (e.g. the Sandbox dispatch loop) translate these into
14
- # the public {Kobako::SandboxError} / {Kobako::TrapError} taxonomy.
14
+ # the public Kobako::SandboxError / Kobako::TrapError taxonomy.
15
15
  class Error < StandardError; end
16
16
 
17
17
  # Input ended before the type prefix or payload was fully consumed.
@@ -21,7 +21,7 @@ module Kobako
21
21
  # type mapping (e.g. an unknown ext code, or a reserved msgpack tag).
22
22
  class InvalidType < Error; end
23
23
 
24
- # A msgpack `str` payload was not valid UTF-8, or an ext 0x00 Symbol
24
+ # A msgpack +str+ payload was not valid UTF-8, or an ext 0x00 Symbol
25
25
  # payload was not valid UTF-8 — both are wire violations per SPEC.
26
26
  class InvalidEncoding < Error; end
27
27
 
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "msgpack"
4
+
5
+ require_relative "error"
6
+ require_relative "utils"
7
+ require_relative "state"
8
+ require_relative "../handle"
9
+ require_relative "../fault"
10
+
11
+ module Kobako
12
+ module Codec
13
+ # The kobako wire ext-type conversions
14
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types)
15
+ # as pure functions: per-operation decode state is threaded in as an
16
+ # argument, so the module itself holds nothing. #build_factory assembles
17
+ # the one +MessagePack::Factory+ these conversions are registered on.
18
+ module ExtTypes
19
+ # MessagePack ext type code reserved for Symbol
20
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
21
+ # → ext 0x00). Module-private — mirrors +codec::EXT_SYMBOL+ on the
22
+ # Rust side.
23
+ EXT_SYMBOL = 0x00
24
+ # MessagePack ext type code reserved for Capability Handle
25
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
26
+ # → ext 0x01). Module-private — mirrors +codec::EXT_HANDLE+ on the
27
+ # Rust side.
28
+ EXT_HANDLE = 0x01
29
+ # MessagePack ext type code reserved for Exception envelope
30
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
31
+ # → ext 0x02). Module-private — mirrors +codec::EXT_ERRENV+ on the
32
+ # Rust side.
33
+ EXT_ERRENV = 0x02
34
+ private_constant :EXT_SYMBOL, :EXT_HANDLE, :EXT_ERRENV
35
+
36
+ module_function
37
+
38
+ # Assemble a +MessagePack::Factory+ with the three kobako ext types
39
+ # registered, frozen because registration is its only mutation and
40
+ # happens exactly once. The stateful conversions resolve their
41
+ # per-operation state at call time, so one registered factory serves
42
+ # every thread.
43
+ def build_factory
44
+ factory = MessagePack::Factory.new
45
+ register_symbol(factory)
46
+ register_handle(factory)
47
+ register_fault(factory)
48
+ factory.freeze
49
+ end
50
+
51
+ # Symbol-to-name packer for the ext-0x00 registration.
52
+ def pack_symbol(symbol)
53
+ symbol.name
54
+ end
55
+
56
+ # Validate the ext-0x00 payload as UTF-8 and intern. Raises
57
+ # InvalidEncoding on invalid bytes — SPEC forbids the
58
+ # binary-encoding fallback that msgpack-gem's default unpacker
59
+ # would otherwise apply. The re-tag step lives here because the
60
+ # msgpack ext-type unpacker hands us binary bytes; the assertion
61
+ # itself is shared with Decoder via Utils.assert_utf8!. The
62
+ # +"Symbol"+ label keeps the error message in Ruby vocabulary
63
+ # rather than wire-ext-code vocabulary.
64
+ def unpack_symbol(payload)
65
+ name = payload.b.force_encoding(Encoding::UTF_8)
66
+ Utils.assert_utf8!(name, "Symbol payload")
67
+ name.to_sym
68
+ end
69
+
70
+ # Handle-id packer for the ext-0x01 registration: the fixext-4
71
+ # big-endian id frame.
72
+ def pack_handle(handle)
73
+ [handle.id].pack("N")
74
+ end
75
+
76
+ # Peel off the fixext-4 frame, hand the bytes to the
77
+ # Host-Gem-internal +Kobako::Handle.restore+ factory, and
78
+ # translate the +ArgumentError+ raised by Handle's invariants
79
+ # into a wire-layer +InvalidType+ via Codec::Utils.with_boundary.
80
+ # The Value Object owns the id-range contract; this method only
81
+ # owns the frame shape. Records the Handle sighting on +state+ so a
82
+ # Handle-free decode can skip the downstream resolution walk.
83
+ def unpack_handle(payload, state)
84
+ state.record_handle!
85
+ bytes = payload.b
86
+ raise InvalidType, "Handle payload must be 4 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 4
87
+
88
+ id = bytes.unpack1("N") # : Integer
89
+ Codec::Utils.with_boundary { Kobako::Handle.restore(id) }
90
+ end
91
+
92
+ # Encode the inner ext-0x02 map via Encoder (not the raw factory) so
93
+ # the embedded payload flows through the same boundary as a top-level
94
+ # encode — nested kobako values (Handle, nested Fault) reach the
95
+ # registered ext-type packers. A +details+ chain nested past the
96
+ # +state+ depth cap has no wire representation and surfaces as
97
+ # +UnsupportedType+. In a payload position (+state+ inside a
98
+ # forbid_faults bracket) the envelope has no wire representation at
99
+ # all, so the refusal routes the value into the position's
100
+ # non-representable handling — the Dispatcher's auto-wrap rescue,
101
+ # or a raise at the yield site.
102
+ def pack_fault(fault, state)
103
+ if state.faults_forbidden?
104
+ raise UnsupportedType, "Kobako::Fault has no wire representation in a payload position"
105
+ end
106
+
107
+ state.within_ext_frame(UnsupportedType) do
108
+ Encoder.encode("type" => fault.type, "message" => fault.message, "details" => fault.details)
109
+ end
110
+ end
111
+
112
+ # Peel the embedded msgpack map and hand it to +Kobako::Fault.new+
113
+ # inside Decoder.decode's block form, so the value-object's
114
+ # +ArgumentError+ invariants surface as +InvalidType+ through the
115
+ # decoder boundary. Inner decode goes through Decoder (not the raw
116
+ # factory) so the embedded +str+ payloads flow through the same
117
+ # UTF-8 validation as a top-level decode. A nested ext 0x02 in
118
+ # +details+ re-enters this method, so the +state+ ext-frame guard
119
+ # bounds the chain depth to keep it from exhausting the native stack.
120
+ # In a payload position (+state+ inside a forbid_faults bracket) the
121
+ # envelope is a wire violation outright — its sole legal position is
122
+ # the Response fault field.
123
+ def unpack_fault(payload, state)
124
+ if state.faults_forbidden?
125
+ raise InvalidType, "Fault envelope (ext 0x02) is not a legal value in a payload position"
126
+ end
127
+
128
+ state.within_ext_frame(InvalidType) do
129
+ Decoder.decode(payload) do |map|
130
+ raise InvalidType, "Fault payload must be a map" unless map.is_a?(Hash)
131
+
132
+ Kobako::Fault.new(type: map["type"], message: map["message"], details: map["details"])
133
+ end
134
+ end
135
+ end
136
+
137
+ def register_symbol(factory)
138
+ factory.register_type(
139
+ EXT_SYMBOL, Symbol,
140
+ packer: ->(symbol) { ExtTypes.pack_symbol(symbol) },
141
+ unpacker: ->(payload) { ExtTypes.unpack_symbol(payload) }
142
+ )
143
+ end
144
+
145
+ def register_handle(factory)
146
+ factory.register_type(
147
+ EXT_HANDLE, Kobako::Handle,
148
+ packer: ->(handle) { ExtTypes.pack_handle(handle) },
149
+ unpacker: ->(payload) { ExtTypes.unpack_handle(payload, State.current) }
150
+ )
151
+ end
152
+
153
+ def register_fault(factory)
154
+ factory.register_type(
155
+ EXT_ERRENV, Kobako::Fault,
156
+ packer: ->(fault) { ExtTypes.pack_fault(fault, State.current) },
157
+ unpacker: ->(payload) { ExtTypes.unpack_fault(payload, State.current) }
158
+ )
159
+ end
160
+ end
161
+
162
+ # The process-wide registered factory: ext registration is paid once at
163
+ # load, and a registered +MessagePack::Factory+ only reads its type
164
+ # registry afterwards, so every thread shares this instance for byte
165
+ # work.
166
+ FACTORY = ExtTypes.build_factory
167
+ private_constant :FACTORY
168
+ end
169
+ end
@@ -5,16 +5,16 @@ require_relative "../handle"
5
5
  module Kobako
6
6
  module Codec
7
7
  # Substitutes Capability Handles into and out of a Ruby value tree at
8
- # the host↔guest boundary. {deep_wrap} allocates a +Kobako::Handle+ for
8
+ # the host↔guest boundary. #deep_wrap allocates a +Kobako::Handle+ for
9
9
  # each non-wire-representable leaf on the host→guest +#run+ argument
10
- # path; {deep_restore} resolves each wire-decoded Handle back to its
11
- # host object on every guest→host value path. {representable?} is the
12
- # by-value codec-type predicate that decides which leaves {deep_wrap}
10
+ # path; #deep_restore resolves each wire-decoded Handle back to its
11
+ # host object on every guest→host value path. #representable? is the
12
+ # by-value codec-type predicate that decides which leaves #deep_wrap
13
13
  # must wrap: the closed 12-entry wire type set
14
14
  # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type
15
15
  # Mapping).
16
16
  #
17
- # All helpers are pure except {deep_wrap}, whose only side effect is
17
+ # All helpers are pure except #deep_wrap, whose only side effect is
18
18
  # allocating new Handle ids into the supplied table.
19
19
  module HandleWalk
20
20
  module_function
@@ -24,7 +24,7 @@ module Kobako
24
24
  # unsigned +uint 64+ maximum
25
25
  # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type
26
26
  # Mapping #3, the +fixint+ / +int 8..64+ / +uint 8..64+ union).
27
- # Anchored as a +Range+ so {primitive_type?} stays a single
27
+ # Anchored as a +Range+ so #primitive_type? stays a single
28
28
  # dispatch line. This is the codec's encode domain — not to
29
29
  # be confused with the Handle id range, which lives on
30
30
  # +Kobako::Handle+ as +MIN_ID+ / +MAX_ID+ (1..2^31 − 1) and
@@ -41,13 +41,13 @@ module Kobako
41
41
  # representable. Integers outside the codec's signed-64 /
42
42
  # unsigned-64 union are rejected so the predicate agrees with the
43
43
  # msgpack gem's encode-time +RangeError+ behaviour the codec
44
- # already surfaces as {UnsupportedType}.
44
+ # already surfaces as UnsupportedType.
45
45
  def representable?(value)
46
46
  primitive_type?(value) || container_representable?(value)
47
47
  end
48
48
 
49
49
  # Deep-walk Array / Hash containers in +value+ and replace every
50
- # leaf that fails {representable?} with a +Kobako::Handle+
50
+ # leaf that fails #representable? with a +Kobako::Handle+
51
51
  # allocated from +handler+. The
52
52
  # walk only descends through representable container shapes
53
53
  # (Array, Hash) one structural level at a time; a non-representable
@@ -61,13 +61,9 @@ module Kobako
61
61
  # whose leaves are either representable or +Kobako::Handle+
62
62
  # tokens.
63
63
  #
64
- # The block bodies spell +HandleWalk.deep_wrap+ explicitly rather
65
- # than the unqualified +deep_wrap+ because +module_function+ makes
66
- # the instance copy of these helpers private; an implicit receiver
67
- # inside a block would resolve against the enclosing +self+
68
- # (still +HandleWalk+ at definition time, but the qualified form
69
- # keeps the dispatch readable when the recursive call sits inside a
70
- # Proc captured from elsewhere).
64
+ # Recursive calls spell the +HandleWalk.+ receiver so the dispatch
65
+ # stays valid even when a block is captured and run under a
66
+ # different +self+ (+module_function+ privatizes the instance copies).
71
67
  def deep_wrap(value, handler)
72
68
  case value
73
69
  when ::Array then value.map { |element| HandleWalk.deep_wrap(element, handler) }
@@ -79,7 +75,7 @@ module Kobako
79
75
 
80
76
  # Deep-walk Array / Hash containers in +value+ and replace every
81
77
  # +Kobako::Handle+ leaf with the host-side object +handler+ resolves
82
- # it to. The symmetric inverse of {deep_wrap}: that walk allocates objects
78
+ # it to. The symmetric inverse of #deep_wrap: that walk allocates objects
83
79
  # into Handles on the host→guest argument path; this walk resolves
84
80
  # Handles back to their objects wherever a guest→host payload carries
85
81
  # one — an invocation result, a yield-block result, or a dispatch
@@ -107,9 +103,9 @@ module Kobako
107
103
  end
108
104
  end
109
105
 
110
- # The non-container branch of {representable?}: returns +true+ for
106
+ # The non-container branch of #representable?: returns +true+ for
111
107
  # the scalar leaves and an existing Handle. Not part of the
112
- # public surface; reach for {representable?} instead.
108
+ # public surface; reach for #representable? instead.
113
109
  def primitive_type?(value)
114
110
  case value
115
111
  when ::NilClass, ::TrueClass, ::FalseClass, ::Float, ::String, ::Symbol, Kobako::Handle then true
@@ -118,10 +114,10 @@ module Kobako
118
114
  end
119
115
  end
120
116
 
121
- # The container branch of {representable?}: recurses into Array
117
+ # The container branch of #representable?: recurses into Array
122
118
  # elements and Hash key+value pairs through the public
123
- # {representable?}. Not part of the public surface; reach for
124
- # {representable?} instead.
119
+ # #representable?. Not part of the public surface; reach for
120
+ # #representable? instead.
125
121
  def container_representable?(value)
126
122
  case value
127
123
  when ::Array then value.all? { |element| HandleWalk.representable?(element) }
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Codec
5
+ # Codec-internal, per-thread state of the operation in flight: the
6
+ # ext-envelope nesting depth and whether a Capability Handle crossed
7
+ # the current decode. Thread scoping is what makes plain instance
8
+ # variables sound — host codec calls run synchronously on their
9
+ # owning thread, and a nested decode (an ext 0x02 Fault re-entering
10
+ # through its +details+) reuses the same thread instance, so the
11
+ # depth counter accumulates across the re-entry instead of resetting.
12
+ class State
13
+ # An ext 0x02 (Fault) envelope nests through its +details+ field, and
14
+ # each level re-enters the codec with a fresh +MessagePack+ unpacker
15
+ # whose built-in stack guard resets — so ext-envelope depth is tracked
16
+ # here instead. The cap matches the wire's overall nesting bound and
17
+ # keeps a nested chain from exhausting the native stack: an over-deep
18
+ # chain fails as a clean wire error, never a stack-level trap.
19
+ MAX_EXT_DEPTH = 128
20
+ private_constant :MAX_EXT_DEPTH
21
+
22
+ # Thread-local slot holding the calling thread's State.
23
+ STATE_KEY = :__kobako_codec_state__
24
+ private_constant :STATE_KEY
25
+
26
+ # The calling thread's State, built on first use so the mutable
27
+ # state stays isolated to the thread that runs the codec call.
28
+ def self.current
29
+ Thread.current[STATE_KEY] ||= new
30
+ end
31
+ private_class_method :new
32
+
33
+ def initialize
34
+ @ext_depth = 0
35
+ @carried_handle = false
36
+ @faults_forbidden = false
37
+ end
38
+
39
+ # Bracket a decode and return the block's result together with
40
+ # whether the decoded tree carried an ext 0x01 Capability Handle.
41
+ # ExtTypes#unpack_handle is the sole chokepoint every Handle passes
42
+ # through, so one decode pass records the whole tree and a caller
43
+ # can skip an all-identity Handle-resolution walk when none was
44
+ # present.
45
+ def track_handles
46
+ @carried_handle = false
47
+ result = yield
48
+ [result, @carried_handle]
49
+ end
50
+
51
+ # Record that an ext 0x01 Capability Handle crossed the current
52
+ # decode; #track_handles reports it to the bracketing caller.
53
+ def record_handle!
54
+ @carried_handle = true
55
+ end
56
+
57
+ # Bracket a codec operation in a payload position, where an ext 0x02
58
+ # Fault envelope has no legal wire representation: the fault field of
59
+ # an error Response is its only home. The ext-type conversions
60
+ # consult #faults_forbidden? and refuse the envelope in both
61
+ # directions while the bracket is open. Save/restore keeps a nested
62
+ # legal operation on the same thread unaffected.
63
+ def forbid_faults
64
+ previous = @faults_forbidden
65
+ @faults_forbidden = true
66
+ yield
67
+ ensure
68
+ @faults_forbidden = previous
69
+ end
70
+
71
+ # Whether the operation in flight sits inside a #forbid_faults
72
+ # bracket — i.e. in a payload position where ext 0x02 is a wire
73
+ # violation.
74
+ def faults_forbidden?
75
+ @faults_forbidden
76
+ end
77
+
78
+ # Track ext-envelope re-entry depth and refuse a chain past
79
+ # MAX_EXT_DEPTH, raising +over_limit+ so the failure lands in the
80
+ # caller's existing wire-error class. The next depth is checked before
81
+ # it is committed, so an over-deep rejection leaves the counter
82
+ # untouched, and the +ensure+ restores the entry value on the way out.
83
+ def within_ext_frame(over_limit)
84
+ depth = @ext_depth + 1
85
+ raise over_limit, "ext envelope nesting exceeds #{MAX_EXT_DEPTH} levels" if depth > MAX_EXT_DEPTH
86
+
87
+ @ext_depth = depth
88
+ begin
89
+ yield
90
+ ensure
91
+ @ext_depth = depth - 1
92
+ end
93
+ end
94
+ end
95
+
96
+ private_constant :State
97
+ end
98
+ end