Module: Kobako::Transport::Dispatcher

Defined in:
lib/kobako/transport/dispatcher.rb,
sig/kobako/transport/dispatcher.rbs

Overview

Pure-function dispatcher for guest-initiated transport calls. Decodes a msgpack-encoded Request envelope, resolves the target object through the Catalog::Namespaces (path lookup) or Catalog::Handles (Handle lookup), invokes the method, and returns a msgpack-encoded Response envelope.

The module is stateless — all mutable state is threaded through arguments so Dispatcher has no instance variables and no side effects beyond mutating the Catalog::Handles via alloc when a non-wire-representable return value must be wrapped.

Entry point:

Kobako::Transport::Dispatcher.dispatch(request_bytes, namespaces, handler, yield_to_guest)
# => msgpack-encoded Response bytes (never raises)

Defined Under Namespace

Classes: UndefinedTargetError

Constant Summary collapse

BREAK_THROW =

Throw tag for the Yielder's break unwind back to the dispatcher's catch frame. private_constant is a convention boundary — not a defence.

Returns:

  • (Symbol)
:__kobako_break__
META_OWNERS =

Modules whose instance methods are ambient Ruby reflection / metaprogramming surface (+send+, public_send, instance_eval, method, tap, instance_variable_get, ...) rather than Service behaviour. A guest-supplied method name resolving to one of these is rejected: only methods the bound object itself exposes as Service behaviour are reachable, and public_send(:send, ...) would otherwise let a guest pivot through send into the private Kernel#eval / #system surface (host RCE).

Returns:

  • (Array[Module])
[BasicObject, Kernel, Object, Module, Class].freeze
GADGET_OWNERS =

Callable gadget types whose own public methods are reflection surface (+Proc#binding+ reaches Binding#eval, Method#receiver / #unbind hand back the underlying object) rather than Service behaviour. Only CALLABLE_ALLOW is reachable on a target of these types; a bound lambda stays invocable, its reflective surface does not.

Returns:

  • (Array[Module])
[Proc, Method, UnboundMethod, Binding].freeze
CALLABLE_ALLOW =

The sole methods reachable on a GADGET_OWNERS target: invoking it (+call+ / [] / yield) and the harmless arity / lambda? describers that aid guest-side debugging.

Returns:

  • (Array[Symbol])
%i[call [] yield arity lambda?].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.dispatch(request_bytes, namespaces, handler, yield_to_guest) ⇒ Object

Dispatch a single transport request and return the encoded Response bytes. Invoked from the Runtime#on_dispatch Proc that Kobako::Sandbox#initialize installs on the ext side; namespaces, handler, and yield_to_guest are captured in that Proc's closure so the Dispatcher stays stateless and the registry doesn't need to publish accessors for the Sandbox-owned Catalog::Handles or Runtime. yield_to_guest is a String → String callable (the ext's per-dispatch Kobako::Runtime::GuestYielder) used only when the Request carries block_given: true. Always returns a binary String — every failure path is reified as a Response.error envelope so the guest sees a transport error rather than a wasm trap.



80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/kobako/transport/dispatcher.rb', line 80

def dispatch(request_bytes, namespaces, handler, yield_to_guest)
  request = Kobako::Transport::Request.decode(request_bytes)
  target = resolve_target(request.target, namespaces, handler)
  args, kwargs = resolve_call_args(request, handler)
  yielder = Yielder.new(yield_to_guest, BREAK_THROW, handler) if request.block_given
  value = catch(BREAK_THROW) { invoke(target, request.method_name, args, kwargs, yielder) }
  encode_ok(value, handler)
rescue StandardError => e
  encode_caught_error(e)
ensure
  yielder&.invalidate!
end

.encode_caught_error(error) ⇒ Object

Map an error caught at the dispatch boundary to a Response.error envelope (binary msgpack). error is the StandardError caught by #dispatch's rescue; the type field tells the guest which kind of failure it was so it can raise the matching proxy-side error.



106
107
108
109
110
111
112
113
114
# File 'lib/kobako/transport/dispatcher.rb', line 106

def encode_caught_error(error)
  case error
  when Kobako::Codec::Error then encode_error("runtime",
                                              "Sandbox received a malformed request: #{error.message}")
  when UndefinedTargetError then encode_error("undefined", error.message)
  when ArgumentError        then encode_error("argument", error.message)
  else                           encode_error("runtime", "#{error.class}: #{error.message}")
  end
end

.encode_error(type, message) ⇒ Object



232
233
234
235
236
# File 'lib/kobako/transport/dispatcher.rb', line 232

def encode_error(type, message)
  fault = Kobako::Fault.new(type: type, message: message)
  response = Kobako::Transport::Response.error(fault)
  response.encode
end

.encode_ok(value, handler) ⇒ Object

Encode value as a Response.ok envelope. When the value is not wire-representable per the codec's type mapping, the UnsupportedType rescue routes it through the Catalog::Handles via #wrap_as_handle and re-encodes with the Capability Handle in place. The happy path encodes exactly once.



218
219
220
221
222
223
# File 'lib/kobako/transport/dispatcher.rb', line 218

def encode_ok(value, handler)
  response = Kobako::Transport::Response.ok(value)
  response.encode
rescue Kobako::Codec::UnsupportedType
  encode_ok(wrap_as_handle(value, handler), handler)
end

.invoke(target, method, args, kwargs, yielder = nil) ⇒ Object

Dispatch method on target. kwargs is already Symbol-keyed (the Request invariant pins it). The empty-kwargs branch omits the ** splat so Ruby 3.x's strict kwargs separation does not reject calls to no-kwarg methods when the wire carries the uniform empty-map shape.

yielder is the host-side Yielder materialised when the guest call site supplied a block; its Yielder#to_proc rides the &block slot. &nil is a no-op block argument in Ruby, so the same call site handles both cases without an explicit conditional.



127
128
129
130
131
132
133
134
135
136
137
# File 'lib/kobako/transport/dispatcher.rb', line 127

def invoke(target, method, args, kwargs, yielder = nil)
  name = method.to_sym
  reject_meta_method!(target, name)
  reject_unexposed!(target, name)
  block = yielder&.to_proc
  if kwargs.empty?
    target.public_send(name, *args, &block)
  else
    target.public_send(name, *args, **kwargs, &block)
  end
end

.reject_meta_method!(target, name) ⇒ Object

Guard the public_send below against ambient reflection methods. A public method whose owner is a META_OWNERS or GADGET_OWNERS module is rejected, except CALLABLE_ALLOW on a gadget target (a bound lambda stays invocable). A name with no concrete public method is allowed only when the target opts into it via respond_to? (dynamic method_missing Services), since the dangerous methods are all concretely defined and therefore never reach that branch.



146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/kobako/transport/dispatcher.rb', line 146

def reject_meta_method!(target, name)
  owner = target.public_method(name).owner
  gadget = GADGET_OWNERS.include?(owner)
  return unless META_OWNERS.include?(owner) || gadget
  return if gadget && CALLABLE_ALLOW.include?(name)

  raise UndefinedTargetError, "method #{name.inspect} is not a Service method"
rescue NameError
  return if target.respond_to?(name)

  raise UndefinedTargetError, "no public method #{name.inspect} on target"
end

.reject_unexposed!(target, name) ⇒ Object

Consult the target's opt-in narrowing predicate. A bound object may define a private respond_to_guest?(name) to restrict which of its methods the guest reaches; a falsy answer rejects the dispatch. The predicate composes beneath #reject_meta_method! — it only narrows, never re-opening the reflection surface the floor rejects — and is consulted with the private surface included so the guest's public_send dispatch can never reach respond_to_guest? itself.



166
167
168
169
170
171
# File 'lib/kobako/transport/dispatcher.rb', line 166

def reject_unexposed!(target, name)
  return unless target.respond_to?(:respond_to_guest?, true)
  return if target.__send__(:respond_to_guest?, name)

  raise UndefinedTargetError, "method #{name.inspect} is not exposed to the guest"
end

.require_live_object!(id, handler) ⇒ Object

Resolve id through the Catalog::Handles. An unknown id surfaces as UndefinedTargetError.



207
208
209
210
211
# File 'lib/kobako/transport/dispatcher.rb', line 207

def require_live_object!(id, handler)
  handler.fetch(id)
rescue Kobako::SandboxError => e
  raise UndefinedTargetError, e.message
end

.resolve_arg(value, handler) ⇒ Object

Resolve every Kobako::Handle in an argument — bare or nested in an Array / Hash — back to its host object before the dispatch reaches public_send, symmetric with the guest→host return path. A Handle id with no live entry surfaces as an unrecognized target.



177
178
179
180
181
# File 'lib/kobako/transport/dispatcher.rb', line 177

def resolve_arg(value, handler)
  Kobako::Codec::HandleWalk.deep_restore(value, handler)
rescue Kobako::SandboxError => e
  raise UndefinedTargetError, e.message
end

.resolve_call_args(request, handler) ⇒ Object

Resolve positional and keyword arguments off request in one step. Both pass through #resolve_arg so Capability Handles round-trip back to the host-side Ruby object before the call reaches public_send.



97
98
99
100
# File 'lib/kobako/transport/dispatcher.rb', line 97

def resolve_call_args(request, handler)
  [request.args.map { |v| resolve_arg(v, handler) },
   request.kwargs.transform_values { |v| resolve_arg(v, handler) }]
end

.resolve_path(path, namespaces) ⇒ Object



199
200
201
202
203
# File 'lib/kobako/transport/dispatcher.rb', line 199

def resolve_path(path, namespaces)
  namespaces.lookup(path)
rescue KeyError => e
  raise UndefinedTargetError, e.message
end

.resolve_target(target, namespaces, handler) ⇒ Object

Resolve a Request target to the Ruby object the registry (or Catalog::Handles) holds. String targets go through the registry; Handle targets (ext 0x01) go through the Catalog::Handles.

Target type is already validated by Transport::Request.decode before this method is reached, so no else-branch is needed here — the wire layer is the system boundary that enforces the invariant.



190
191
192
193
194
195
196
197
# File 'lib/kobako/transport/dispatcher.rb', line 190

def resolve_target(target, namespaces, handler)
  case target
  when String
    resolve_path(target, namespaces)
  when Kobako::Handle
    require_live_object!(target.id, handler)
  end
end

.wrap_as_handle(value, handler) ⇒ Object

Allocate value in the Sandbox's Catalog::Handles and return a Handle that the wire codec can carry. Used as the fallback path of #encode_ok when value has no wire representation.



228
229
230
# File 'lib/kobako/transport/dispatcher.rb', line 228

def wrap_as_handle(value, handler)
  handler.alloc(value)
end

Instance Method Details

#self?.dispatchString

Parameters:

Returns:

  • (String)


15
# File 'sig/kobako/transport/dispatcher.rbs', line 15

def self?.dispatch: (String request_bytes, Kobako::Catalog::Namespaces namespaces, Kobako::Catalog::Handles handler, Kobako::Transport::_GuestYielder yield_to_guest) -> String

#self?.encode_caught_errorString

Parameters:

  • error (StandardError)

Returns:

  • (String)


19
# File 'sig/kobako/transport/dispatcher.rbs', line 19

def self?.encode_caught_error: (StandardError error) -> String

#self?.encode_errorString

Parameters:

  • type (String)
  • message (String)

Returns:

  • (String)


39
# File 'sig/kobako/transport/dispatcher.rbs', line 39

def self?.encode_error: (String type, String message) -> String

#self?.encode_okString

Parameters:

Returns:

  • (String)


35
# File 'sig/kobako/transport/dispatcher.rbs', line 35

def self?.encode_ok: (untyped value, Kobako::Catalog::Handles handler) -> String

#self?.invokeObject

Parameters:

Returns:

  • (Object)


21
# File 'sig/kobako/transport/dispatcher.rbs', line 21

def self?.invoke: (untyped target, String method, Array[untyped] args, Hash[Symbol, untyped] kwargs, ?Kobako::Transport::Yielder? yielder) -> untyped

#self?.reject_meta_method!void

This method returns an undefined value.

Parameters:

  • target (Object)
  • name (Symbol)


23
# File 'sig/kobako/transport/dispatcher.rbs', line 23

def self?.reject_meta_method!: (untyped target, Symbol name) -> void

#self?.reject_unexposed!void

This method returns an undefined value.

Parameters:

  • target (Object)
  • name (Symbol)


25
# File 'sig/kobako/transport/dispatcher.rbs', line 25

def self?.reject_unexposed!: (untyped target, Symbol name) -> void

#self?.require_live_object!Object

Parameters:

Returns:

  • (Object)


33
# File 'sig/kobako/transport/dispatcher.rbs', line 33

def self?.require_live_object!: (Integer id, Kobako::Catalog::Handles handler) -> untyped

#self?.resolve_argObject

Parameters:

Returns:

  • (Object)


27
# File 'sig/kobako/transport/dispatcher.rbs', line 27

def self?.resolve_arg: (untyped value, Kobako::Catalog::Handles handler) -> untyped

#self?.resolve_call_args[Array[untyped], Hash[Symbol, untyped]]

Parameters:

Returns:

  • ([Array[untyped], Hash[Symbol, untyped]])


17
# File 'sig/kobako/transport/dispatcher.rbs', line 17

def self?.resolve_call_args: (Kobako::Transport::Request request, Kobako::Catalog::Handles handler) -> [Array[untyped], Hash[Symbol, untyped]]

#self?.resolve_pathObject

Parameters:

Returns:

  • (Object)


31
# File 'sig/kobako/transport/dispatcher.rbs', line 31

def self?.resolve_path: (String path, Kobako::Catalog::Namespaces namespaces) -> untyped

#self?.resolve_targetObject

Parameters:

Returns:

  • (Object)


29
# File 'sig/kobako/transport/dispatcher.rbs', line 29

def self?.resolve_target: (String | Kobako::Handle target, Kobako::Catalog::Namespaces namespaces, Kobako::Catalog::Handles handler) -> untyped

#self?.wrap_as_handleKobako::Handle

Parameters:

Returns:



37
# File 'sig/kobako/transport/dispatcher.rbs', line 37

def self?.wrap_as_handle: (untyped value, Kobako::Catalog::Handles handler) -> Kobako::Handle