Module: Kobako::Transport::Dispatcher

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

Overview

Pure-function dispatcher for guest-initiated Calls. The native side has already decoded the core envelope, so this resolves the target through the per-invocation path resolver (the Context, whose #lookup layers per-invocation providers over the static bindings) or Catalog::Handles, decodes only the payload, invokes the method, and answers [ok, bytes] — which the native side puts on the Reply's ok or fault arm. It never raises.

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.

Defined Under Namespace

Classes: UndefinedTargetError, UnreadableRequestError

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__
OWN_FAULTS =

The category kobako's own refusals answer under, keyed by the class each is raised as and ordered most specific first. A class absent here is the Service's own exception, which answers under runtime wearing the <class>: <message> shape that says so.

Kobako::Codec::Error is the floor rather than a path of its own: a codec fault reaching the boundary unnamed is the exchange failing, and must not be dressed as something a Service raised.

Returns:

  • (Hash[singleton(StandardError), String])
{
  HandleExhaustedError => "internal",
  UndefinedTargetError => "undefined",
  ArgumentError => "argument",
  YieldValueError => "runtime",
  Kobako::Codec::Error => "internal",
  Kobako::SandboxError => "runtime"
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.caught_fault(error, yielder) ⇒ Object

Map an error caught at the dispatch boundary to the message and the category the native side frames into the Reply's fault arm. error is the StandardError caught by #dispatch's rescue; the category tells the guest which kind of failure it was so it can raise the matching proxy-side error.

The class prefix marks a Service's own exception and nothing else: it is the <class>: <message> shape a Host App is told to keep secrets out of, so wearing it says the Service raised. kobako's own refusals answer under their own wording instead of borrowing that shape.

The guest's own block failing is not the Service's to report at all, so the Yielder that raised it is asked first — it recognises its own by identity and words the failure the guest's way.



143
144
145
146
147
148
149
150
151
# File 'lib/kobako/transport/dispatcher.rb', line 143

def caught_fault(error, yielder)
  block_failure = yielder&.fault_text(error)
  return fault("block", block_failure) if block_failure

  own = OWN_FAULTS.find { |klass, _| error.is_a?(klass) }
  return fault(own.last, error.message) if own

  fault("runtime", "#{error.class}: #{error.message}")
end

.decode_arguments(payload) ⇒ Object

Decode the Call's payload into its arguments, reporting whether any Capability Handle crossed. A codec fault here is a request that never became a call, restated so it cannot read as an unwritable reply — the same restatement #encode_ok makes in the other direction.



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

def decode_arguments(payload)
  Kobako::Codec.track_handles { Payload::Arguments.decode(payload) }
rescue Kobako::Codec::Error => e
  raise UnreadableRequestError, "Sandbox could not read the request: #{e.message}"
end

.dispatch(call, resolver, handler, yield_to_guest) ⇒ Object

Answer a single routed Call with [ok, bytes], which the native side puts on the Reply's ok or fault arm. Invoked from the per-invocation dispatch Proc that Kobako::Context hands to Runtime#eval / #run; resolver, handler, and yield_to_guest are captured in that Proc's closure so the Dispatcher stays stateless and neither the resolver nor the Context needs to publish accessors for the per-invocation Catalog::Handles or Runtime. yield_to_guest is a String → String callable (the ext's per-dispatch Kobako::Runtime::GuestYielder) used only when the Call carries block_given: true. Never raises — every failure path takes the fault arm instead, so the guest sees a transport error rather than a wasm trap.

The decode runs inside Codec.track_handles so #resolve_call_args can skip the argument walk when no Capability Handle crossed the wire.



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

def dispatch(call, resolver, handler, yield_to_guest)
  yielder = Yielder.new(yield_to_guest, BREAK_THROW, handler) if call.block_given
  [true, encode_ok(run(call, resolver, handler, yielder), handler), nil] # : [bool, String, String?]
# StandardError is the boundary by intent: a Service method's
# application fault folds into a guest-rescuable fault, while a
# host-process failure (NoMemoryError, SignalException, a bare Exception)
# stays uncaught and traps the invocation rather than being masked as a
# rescuable fault.
rescue StandardError => e
  [false, *caught_fault(e, yielder)] # : [bool, String, String?]
ensure
  yielder&.invalidate!
end

.encode_ok(value, handler) ⇒ Object

Encode value as the body of a Reply's ok arm — the value alone, since the envelope's tag already carries the success. A value that is not wire-representable per the codec's type mapping raises UnsupportedTypeError; the 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.

Any other codec fault is the answer failing to encode rather than the request failing to decode, and a Service is the only side that can change what it returns — so it is named here, where the direction is known, instead of falling to the boundary's codec floor and reporting as an exchange that produced no Service outcome.



236
237
238
239
240
241
242
# File 'lib/kobako/transport/dispatcher.rb', line 236

def encode_ok(value, handler)
  Kobako::Codec::Encoder.encode(value)
rescue Kobako::Codec::UnsupportedTypeError
  encode_ok(wrap_as_handle(value, handler), handler)
rescue Kobako::Codec::Error => e
  raise Kobako::SandboxError, "Sandbox could not write the Service's answer: #{e.message}"
end

.fault(type, message) ⇒ Object

message folds to UTF-8 first: Ruby core builds some exception messages as ASCII-8BIT (the arity ArgumentError, for one), and the envelope requires UTF-8 of the text fields it frames.



254
255
256
# File 'lib/kobako/transport/dispatcher.rb', line 254

def fault(type, message)
  [message.encode(Encoding::UTF_8, invalid: :replace, undef: :replace), type] # : [String, String]
end

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

Dispatch method on target. kwargs is already Symbol-keyed (the Payload::Arguments 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.



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

def invoke(target, method, args, kwargs, yielder = nil)
  name = method.to_sym
  reject_unreachable!(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_unreachable!(target, name) ⇒ Object

Guard the public_send below: Reflection decides what counts as Service behaviour on this target, and its refusal reason becomes the guest's undefined fault. Both the ambient-surface floor and the target's own narrowing predicate answer through it, so a rejected name discloses nothing about which of the two refused.



180
181
182
183
# File 'lib/kobako/transport/dispatcher.rb', line 180

def reject_unreachable!(target, name)
  reason = Reflection.refusal(target, name)
  raise UndefinedTargetError, reason if reason
end

.require_live_object!(id, handler) ⇒ Object

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



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

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.



189
190
191
192
193
# File 'lib/kobako/transport/dispatcher.rb', line 189

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

.resolve_call_args(arguments, handler, carried_handle) ⇒ Object

Resolve positional and keyword arguments off the decoded payload in one step. carried_handle reports whether the decode carried any Capability Handle; when it did not, every argument resolves to itself, so the decoded values pass straight through and the walk is skipped entirely. Otherwise both go through #resolve_arg so Handles round-trip back to the host-side Ruby object before the call reaches public_send.



121
122
123
124
125
126
# File 'lib/kobako/transport/dispatcher.rb', line 121

def resolve_call_args(arguments, handler, carried_handle)
  return [arguments.args, arguments.kwargs] unless carried_handle

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

.resolve_path(path, resolver) ⇒ Object



210
211
212
213
214
# File 'lib/kobako/transport/dispatcher.rb', line 210

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

.resolve_target(target, resolver, handler) ⇒ Object

Resolve a Call target to the Ruby object the path resolver (or Catalog::Handles) holds. The native side already discriminated the two forms off the core envelope's kind tag: a String is a bound constant's path, an Integer is a Capability Handle id. No else-branch is needed — the envelope layer is the system boundary that enforces the invariant.



201
202
203
204
205
206
207
208
# File 'lib/kobako/transport/dispatcher.rb', line 201

def resolve_target(target, resolver, handler)
  case target
  when String
    resolve_path(target, resolver)
  when Integer
    require_live_object!(target, handler)
  end
end

.run(call, resolver, handler, yielder) ⇒ Object

Decode the payload, resolve the receiver, and run the method inside the catch frame a guest break unwinds to. Split from #dispatch so the reply-shaping and the failure boundary stay one glance wide.



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

def run(call, resolver, handler, yielder)
  arguments, carried_handle = decode_arguments(call.payload)
  receiver = resolve_target(call.target, resolver, handler)
  args, kwargs = resolve_call_args(arguments, handler, carried_handle)
  catch(BREAK_THROW) { invoke(receiver, call.method_name, args, kwargs, yielder) }
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.



247
248
249
# File 'lib/kobako/transport/dispatcher.rb', line 247

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

Instance Method Details

#self?.caught_fault[String, String]

Parameters:

Returns:

  • ([String, String])


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

def self?.caught_fault: (StandardError error, Kobako::Transport::Yielder? yielder) -> [String, String]

#self?.decode_arguments[Kobako::Payload::Arguments, bool]

Parameters:

  • payload (String)

Returns:



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

def self?.decode_arguments: (String payload) -> [Kobako::Payload::Arguments, bool]

#self?.dispatch[bool, String, String?]

Parameters:

Returns:

  • ([bool, String, String?])


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

def self?.dispatch: (Kobako::Transport::Call call, Kobako::Transport::_ServiceRegistry resolver, Kobako::Codec::_HandleTable handler, Kobako::Transport::_GuestYielder yield_to_guest) -> [bool, String, String?]

#self?.encode_okString

Parameters:

Returns:

  • (String)


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

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

#self?.fault[String, String]

Parameters:

  • type (String)
  • message (String)

Returns:

  • ([String, String])


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

def self?.fault: (String type, String message) -> [String, String]

#self?.invokeObject

Parameters:

Returns:

  • (Object)


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

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

#self?.reject_unreachable!void

This method returns an undefined value.

Parameters:

  • target (Object)
  • name (Symbol)


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

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

#self?.require_live_object!Object

Parameters:

Returns:

  • (Object)


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

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

#self?.resolve_argObject

Parameters:

Returns:

  • (Object)


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

def self?.resolve_arg: (untyped value, Kobako::Codec::_HandleTable handler) -> untyped

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

Parameters:

Returns:

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


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

def self?.resolve_call_args: (Kobako::Payload::Arguments arguments, Kobako::Codec::_HandleTable handler, bool carried_handle) -> [Array[untyped], Hash[Symbol, untyped]]

#self?.resolve_pathObject

Parameters:

Returns:

  • (Object)


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

def self?.resolve_path: (String path, Kobako::Transport::_ServiceRegistry resolver) -> untyped

#self?.resolve_targetObject

Parameters:

Returns:

  • (Object)


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

def self?.resolve_target: (String | Integer target, Kobako::Transport::_ServiceRegistry resolver, Kobako::Codec::_HandleTable handler) -> untyped

#self?.runObject

Parameters:

Returns:

  • (Object)


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

def self?.run: (Kobako::Transport::Call call, Kobako::Transport::_ServiceRegistry resolver, Kobako::Codec::_HandleTable handler, Kobako::Transport::Yielder? yielder) -> untyped

#self?.wrap_as_handleKobako::Handle

Parameters:

Returns:



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

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