kobako 0.4.0 → 0.5.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -0
  3. data/CHANGELOG.md +29 -0
  4. data/Cargo.lock +1 -1
  5. data/README.md +0 -1
  6. data/data/kobako.wasm +0 -0
  7. data/ext/kobako/Cargo.toml +1 -1
  8. data/ext/kobako/src/lib.rs +4 -2
  9. data/ext/kobako/src/{wasm → runtime}/cache.rs +12 -16
  10. data/ext/kobako/src/runtime/capture.rs +91 -0
  11. data/ext/kobako/src/runtime/config.rs +26 -0
  12. data/ext/kobako/src/runtime/dispatch.rs +211 -0
  13. data/ext/kobako/src/runtime/exports.rs +51 -0
  14. data/ext/kobako/src/runtime/guest_mem.rs +228 -0
  15. data/ext/kobako/src/{wasm/host_state.rs → runtime/invocation.rs} +94 -86
  16. data/ext/kobako/src/runtime/trap.rs +134 -0
  17. data/ext/kobako/src/runtime.rs +782 -0
  18. data/ext/kobako/src/snapshot.rs +110 -0
  19. data/lib/kobako/capture.rb +11 -16
  20. data/lib/kobako/catalog/handles.rb +107 -0
  21. data/lib/kobako/catalog/namespaces.rb +99 -0
  22. data/lib/kobako/{snippet/table.rb → catalog/snippets.rb} +37 -62
  23. data/lib/kobako/catalog.rb +18 -0
  24. data/lib/kobako/codec/decoder.rb +13 -5
  25. data/lib/kobako/codec/factory.rb +12 -12
  26. data/lib/kobako/codec/utils.rb +56 -59
  27. data/lib/kobako/codec.rb +6 -3
  28. data/lib/kobako/errors.rb +45 -28
  29. data/lib/kobako/fault.rb +40 -0
  30. data/lib/kobako/handle.rb +4 -6
  31. data/lib/kobako/namespace.rb +67 -0
  32. data/lib/kobako/outcome.rb +31 -35
  33. data/lib/kobako/runtime.rb +30 -0
  34. data/lib/kobako/sandbox.rb +83 -72
  35. data/lib/kobako/sandbox_options.rb +6 -9
  36. data/lib/kobako/snapshot.rb +40 -0
  37. data/lib/kobako/snippet/binary.rb +6 -7
  38. data/lib/kobako/snippet/source.rb +8 -8
  39. data/lib/kobako/snippet.rb +7 -9
  40. data/lib/kobako/transport/dispatcher.rb +195 -0
  41. data/lib/kobako/{rpc/wire_error.rb → transport/error.rb} +7 -6
  42. data/lib/kobako/transport/request.rb +78 -0
  43. data/lib/kobako/transport/response.rb +69 -0
  44. data/lib/kobako/transport/run.rb +141 -0
  45. data/lib/kobako/transport/yield.rb +91 -0
  46. data/lib/kobako/transport/yielder.rb +89 -0
  47. data/lib/kobako/transport.rb +24 -0
  48. data/lib/kobako/version.rb +1 -1
  49. data/lib/kobako.rb +4 -4
  50. data/release-please-config.json +24 -0
  51. data/sig/kobako/capture.rbs +0 -2
  52. data/sig/kobako/catalog/handles.rbs +19 -0
  53. data/sig/kobako/catalog/namespaces.rbs +17 -0
  54. data/sig/kobako/{snippet/table.rbs → catalog/snippets.rbs} +2 -11
  55. data/sig/kobako/{rpc.rbs → catalog.rbs} +1 -1
  56. data/sig/kobako/codec/decoder.rbs +2 -1
  57. data/sig/kobako/codec/factory.rbs +2 -2
  58. data/sig/kobako/codec/utils.rbs +5 -5
  59. data/sig/kobako/errors.rbs +7 -7
  60. data/sig/kobako/fault.rbs +19 -0
  61. data/sig/kobako/handle.rbs +2 -3
  62. data/sig/kobako/namespace.rbs +19 -0
  63. data/sig/kobako/outcome.rbs +2 -2
  64. data/sig/kobako/runtime.rbs +23 -0
  65. data/sig/kobako/sandbox.rbs +5 -8
  66. data/sig/kobako/snapshot.rbs +15 -0
  67. data/sig/kobako/transport/dispatcher.rbs +34 -0
  68. data/sig/kobako/transport/error.rbs +6 -0
  69. data/sig/kobako/transport/request.rbs +32 -0
  70. data/sig/kobako/transport/response.rbs +30 -0
  71. data/sig/kobako/transport/run.rbs +27 -0
  72. data/sig/kobako/transport/yield.rbs +34 -0
  73. data/sig/kobako/transport/yielder.rbs +21 -0
  74. data/sig/kobako/transport.rbs +4 -0
  75. metadata +48 -30
  76. data/ext/kobako/src/wasm/dispatch.rs +0 -162
  77. data/ext/kobako/src/wasm/instance.rs +0 -873
  78. data/ext/kobako/src/wasm.rs +0 -126
  79. data/lib/kobako/handle_table.rb +0 -119
  80. data/lib/kobako/invocation.rb +0 -143
  81. data/lib/kobako/rpc/dispatcher.rb +0 -171
  82. data/lib/kobako/rpc/envelope.rb +0 -118
  83. data/lib/kobako/rpc/fault.rb +0 -41
  84. data/lib/kobako/rpc/namespace.rb +0 -74
  85. data/lib/kobako/rpc/server.rb +0 -146
  86. data/lib/kobako/rpc.rb +0 -11
  87. data/lib/kobako/wasm.rb +0 -25
  88. data/sig/kobako/handle_table.rbs +0 -23
  89. data/sig/kobako/invocation.rbs +0 -25
  90. data/sig/kobako/rpc/dispatcher.rbs +0 -33
  91. data/sig/kobako/rpc/envelope.rbs +0 -51
  92. data/sig/kobako/rpc/fault.rbs +0 -20
  93. data/sig/kobako/rpc/namespace.rbs +0 -24
  94. data/sig/kobako/rpc/server.rbs +0 -31
  95. data/sig/kobako/rpc/wire_error.rbs +0 -6
  96. data/sig/kobako/wasm.rbs +0 -41
@@ -1,126 +0,0 @@
1
- // Host-side wasmtime wrapper.
2
- //
3
- // The only Ruby-visible class is
4
- //
5
- // Kobako::Wasm::Instance — wraps wasmtime::Instance + cached TypedFuncs
6
- //
7
- // constructed via `Kobako::Wasm::Instance.from_path(path, timeout, memory_limit,
8
- // stdout_limit, stderr_limit)`.
9
- // The underlying wasmtime Engine and compiled Module live in a process-scope
10
- // cache (see the `cache` submodule) and never surface to Ruby (SPEC.md "Code
11
- // Organization": `ext/` "exposes no Wasm engine types to the Host App or
12
- // downstream gems").
13
- //
14
- // Module layout (per CLAUDE.md principle #2 — one responsibility per file):
15
- //
16
- // * `cache` — process-wide Engine + per-path Module cache and the
17
- // process-singleton epoch ticker thread.
18
- // * `host_state` — HostState (per-Store context), StoreCell wrapper, the
19
- // [`KobakoLimiter`] memory cap, and the trap marker
20
- // types ([`TimeoutTrap`] / [`MemoryLimitTrap`]).
21
- // * `instance` — Kobako::Wasm::Instance and its run-path methods.
22
- // * `dispatch` — `__kobako_dispatch` host-import dispatch helpers.
23
- //
24
- // This file is the façade: it owns the Ruby error class lazy-resolvers,
25
- // the `wasm_err` / `timeout_err` / `memory_limit_err` constructors shared
26
- // by every submodule, and the Ruby init() that registers
27
- // `Kobako::Wasm::Instance` and its methods.
28
-
29
- mod cache;
30
- mod dispatch;
31
- mod host_state;
32
- mod instance;
33
-
34
- use magnus::value::Lazy;
35
- use magnus::{
36
- function, method, prelude::*, Error as MagnusError, ExceptionClass, RModule, RString, Ruby,
37
- };
38
-
39
- use instance::Instance;
40
-
41
- /// Copy the bytes of +s+ into a fresh +Vec<u8>+. Single safe entry to
42
- /// what would otherwise be an inline +unsafe { rstring.as_slice() }
43
- /// .to_vec()+ duplicated at every host-↔-guest boundary. The borrow
44
- /// does not outlive this call, so no Ruby allocation can move the
45
- /// underlying RString between the borrow and the copy — the safety
46
- /// invariant the inline form relied on is established once here.
47
- pub(crate) fn rstring_to_vec(s: RString) -> Vec<u8> {
48
- // SAFETY: see item doc.
49
- unsafe { s.as_slice() }.to_vec()
50
- }
51
-
52
- // ---------------------------------------------------------------------------
53
- // Error classes (lazy-resolved from Ruby once Kobako::Wasm is defined).
54
- // ---------------------------------------------------------------------------
55
-
56
- pub(crate) static MODULE_NOT_BUILT_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
57
- let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
58
- let wasm: RModule = kobako.const_get("Wasm").unwrap();
59
- wasm.const_get("ModuleNotBuiltError").unwrap()
60
- });
61
-
62
- pub(crate) static WASM_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
63
- let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
64
- let wasm: RModule = kobako.const_get("Wasm").unwrap();
65
- wasm.const_get("Error").unwrap()
66
- });
67
-
68
- pub(crate) static WASM_TIMEOUT_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
69
- let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
70
- let wasm: RModule = kobako.const_get("Wasm").unwrap();
71
- wasm.const_get("TimeoutError").unwrap()
72
- });
73
-
74
- pub(crate) static WASM_MEMORY_LIMIT_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
75
- let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
76
- let wasm: RModule = kobako.const_get("Wasm").unwrap();
77
- wasm.const_get("MemoryLimitError").unwrap()
78
- });
79
-
80
- pub(crate) fn wasm_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
81
- MagnusError::new(ruby.get_inner(&WASM_ERROR), msg.into())
82
- }
83
-
84
- /// Construct a `Kobako::Wasm::TimeoutError` magnus error. Surfaces the
85
- /// docs/behavior.md E-19 wall-clock cap path so the Sandbox layer can rewrap it
86
- /// as `Kobako::TimeoutError`.
87
- pub(crate) fn timeout_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
88
- MagnusError::new(ruby.get_inner(&WASM_TIMEOUT_ERROR), msg.into())
89
- }
90
-
91
- /// Construct a `Kobako::Wasm::MemoryLimitError` magnus error. Surfaces
92
- /// the docs/behavior.md E-20 linear-memory cap path so the Sandbox layer can
93
- /// rewrap it as `Kobako::MemoryLimitError`.
94
- pub(crate) fn memory_limit_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
95
- MagnusError::new(ruby.get_inner(&WASM_MEMORY_LIMIT_ERROR), msg.into())
96
- }
97
-
98
- // ---------------------------------------------------------------------------
99
- // Ruby init
100
- // ---------------------------------------------------------------------------
101
-
102
- pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
103
- let wasm = kobako.define_module("Wasm")?;
104
-
105
- // Error hierarchy. ModuleNotBuiltError is the headline error for the
106
- // common pre-build state where `data/kobako.wasm` has not yet been
107
- // produced (e.g. fresh clone before `rake compile`). TimeoutError and
108
- // MemoryLimitError carry the docs/behavior.md B-01 per-run cap paths up to the
109
- // Sandbox layer.
110
- let base_err = wasm.define_error("Error", ruby.exception_standard_error())?;
111
- wasm.define_error("ModuleNotBuiltError", base_err)?;
112
- wasm.define_error("TimeoutError", base_err)?;
113
- wasm.define_error("MemoryLimitError", base_err)?;
114
-
115
- let instance = wasm.define_class("Instance", ruby.class_object())?;
116
- instance.define_singleton_method("from_path", function!(Instance::from_path, 5))?;
117
- instance.define_method("server=", method!(Instance::set_server, 1))?;
118
- instance.define_method("eval", method!(Instance::eval, 3))?;
119
- instance.define_method("run", method!(Instance::run, 3))?;
120
- instance.define_method("stdout", method!(Instance::stdout, 0))?;
121
- instance.define_method("stderr", method!(Instance::stderr, 0))?;
122
- instance.define_method("outcome!", method!(Instance::outcome, 0))?;
123
- instance.define_method("usage", method!(Instance::usage, 0))?;
124
-
125
- Ok(())
126
- }
@@ -1,119 +0,0 @@
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,143 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "handle"
4
- require_relative "codec"
5
-
6
- module Kobako
7
- # Host-side value object for a single +Sandbox#run+ invocation
8
- # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md];
9
- # {docs/behavior.md B-31}[link:../../docs/behavior.md]).
10
- #
11
- # An Invocation captures the host-layer concept of "a single +#run+
12
- # call": the entrypoint constant name plus its positional and keyword
13
- # arguments. Host pre-flight (E-24 / E-25 / E-29 / E-30) is enforced at
14
- # construction so the Value Object is the single source of truth —
15
- # anything that passes +Invocation.new+ is safe to encode and ship to
16
- # the guest.
17
- #
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.
33
- #
34
- # Built on the +class X < Data.define(...)+ subclass form (the
35
- # Steep-friendly shape — see +lib/kobako/outcome/panic.rb+).
36
- class Invocation < Data.define(:entrypoint, :args, :kwargs)
37
- # Ruby constant-name pattern enforced on the +entrypoint+ Symbol
38
- # ({docs/behavior.md E-25}[link:../../docs/behavior.md]). Parallel to
39
- # +Kobako::Snippet::Table::NAME_PATTERN+; the two constants name the
40
- # same regex but cover distinct surfaces (snippet identity vs.
41
- # entrypoint resolution) so a future divergence stays local.
42
- NAME_PATTERN = /\A[A-Z]\w*\z/
43
-
44
- # steep:ignore:start
45
- def initialize(entrypoint:, args: [], kwargs: {})
46
- super(
47
- entrypoint: normalize_entrypoint(entrypoint),
48
- args: validate_args!(args),
49
- kwargs: validate_kwargs!(kwargs)
50
- )
51
- end
52
- # steep:ignore:end
53
-
54
- # Encode this Invocation to the msgpack bytes the guest's
55
- # +__kobako_run+ entry point consumes as its command-buffer payload
56
- # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]).
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)
69
- Codec::Encoder.encode(
70
- "entrypoint" => entrypoint,
71
- "args" => Codec::Utils.deep_wrap(args, handle_table),
72
- "kwargs" => Codec::Utils.deep_wrap(kwargs, handle_table)
73
- )
74
- end
75
-
76
- private
77
-
78
- # steep:ignore:start
79
- # E-24: target must be a Symbol or String (TypeError, not
80
- # ArgumentError — the wrong-type case is a Host App programming
81
- # error before the invocation reaches the guest). E-25: after
82
- # +.to_s+ the value must match NAME_PATTERN (ArgumentError),
83
- # rejecting +::+-segmented names and any non-constant form.
84
- def normalize_entrypoint(target)
85
- unless target.is_a?(Symbol) || target.is_a?(String)
86
- raise TypeError, "Invocation entrypoint must be a Symbol or String, got #{target.class}"
87
- end
88
-
89
- target_str = target.to_s
90
- unless NAME_PATTERN.match?(target_str)
91
- raise ArgumentError,
92
- "Invocation entrypoint must match #{NAME_PATTERN.inspect} (got #{target.inspect})"
93
- end
94
-
95
- target_str.to_sym
96
- end
97
-
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.
105
- def validate_args!(args)
106
- raise ArgumentError, "Invocation args must be Array" unless args.is_a?(Array)
107
- raise ArgumentError, forged_handle_message("args") if args.any?(Kobako::Handle)
108
-
109
- args
110
- end
111
-
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.
117
- def validate_kwargs!(kwargs)
118
- raise ArgumentError, "Invocation kwargs must be Hash" unless kwargs.is_a?(Hash)
119
-
120
- bad_keys = kwargs.each_key.grep_v(Symbol)
121
- unless bad_keys.empty?
122
- raise ArgumentError,
123
- "Invocation kwargs keys must be Symbols (got #{bad_keys.inspect})"
124
- end
125
- raise ArgumentError, forged_handle_message("kwargs values") if kwargs.each_value.any?(Kobako::Handle)
126
-
127
- kwargs
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
141
- # steep:ignore:end
142
- end
143
- end
@@ -1,171 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Kobako
4
- module RPC
5
- # Pure-function dispatcher for guest-initiated RPC calls. Decodes a
6
- # msgpack-encoded Request envelope, resolves the target object through
7
- # the Server (path lookup or HandleTable lookup), invokes the method,
8
- # and returns a msgpack-encoded Response envelope.
9
- #
10
- # The module is stateless — all mutable state is threaded through the
11
- # +server+ argument so Dispatcher has no instance variables and no side
12
- # effects beyond mutating the HandleTable via +alloc+ when a non-wire-
13
- # representable return value must be wrapped ({docs/behavior.md B-14}[link:../../../docs/behavior.md]).
14
- #
15
- # Entry point:
16
- #
17
- # Kobako::RPC::Dispatcher.dispatch(request_bytes, server)
18
- # # => msgpack-encoded Response bytes (never raises)
19
- module Dispatcher
20
- module_function
21
-
22
- # Internal sentinel raised when target resolution fails. Mapped to
23
- # Response.error with type="undefined". Contained at the wire boundary —
24
- # not part of the public Kobako error taxonomy
25
- # ({docs/behavior.md E-xx}[link:../../../docs/behavior.md]).
26
- class UndefinedTargetError < StandardError; end
27
-
28
- # Internal sentinel raised when a Handle target resolves to the
29
- # +:disconnected+ sentinel in the HandleTable (ABA protection,
30
- # {docs/behavior.md E-14}[link:../../../docs/behavior.md]). Mapped to Response.error with
31
- # type="disconnected". Contained at the wire boundary.
32
- class DisconnectedTargetError < StandardError; end
33
-
34
- # Dispatch a single RPC request and return the encoded response bytes.
35
- # Called by +Kobako::RPC::Server#dispatch+ which is invoked from the
36
- # Rust ext inside +__kobako_dispatch+. +request_bytes+ is the
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 accessor — Handle 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
46
- # ({docs/behavior.md B-12}[link:../../../docs/behavior.md]).
47
- def dispatch(request_bytes, server, handle_table)
48
- request = Kobako::RPC.decode_request(request_bytes)
49
- target = resolve_target(request.target, server, handle_table)
50
- args = request.args.map { |v| resolve_arg(v, handle_table) }
51
- kwargs = request.kwargs.transform_values { |v| resolve_arg(v, handle_table) }
52
- value = invoke(target, request.method_name, args, kwargs)
53
- encode_ok(value, handle_table)
54
- rescue StandardError => e
55
- encode_caught_error(e)
56
- end
57
-
58
- # Map an error caught at the dispatch boundary to a +Response.error+
59
- # envelope. +error+ is the +StandardError+ caught by {#dispatch}'s
60
- # rescue. Returns a msgpack-encoded Response envelope (binary). Four
61
- # error buckets ({docs/behavior.md B-12}[link:../../../docs/behavior.md]):
62
- # +Kobako::Codec::Error+ → type="runtime" (malformed RPC request);
63
- # +DisconnectedTargetError+ → type="disconnected" (E-14);
64
- # +UndefinedTargetError+ → type="undefined" (E-13); +ArgumentError+ →
65
- # type="argument" (B-12 arity mismatch); everything else →
66
- # type="runtime".
67
- def encode_caught_error(error)
68
- case error
69
- when Kobako::Codec::Error then encode_error("runtime",
70
- "Sandbox received a malformed RPC request: #{error.message}")
71
- when DisconnectedTargetError then encode_error("disconnected", error.message)
72
- when UndefinedTargetError then encode_error("undefined", error.message)
73
- when ArgumentError then encode_error("argument", error.message)
74
- else encode_error("runtime", "#{error.class}: #{error.message}")
75
- end
76
- end
77
-
78
- # Dispatch +method+ on +target+. +kwargs+ is already Symbol-keyed
79
- # (the +Envelope::Request+ invariant pins it). The empty-kwargs
80
- # branch omits the +**+ splat so Ruby 3.x's strict kwargs
81
- # separation does not reject calls to no-kwarg methods when the
82
- # wire carries the uniform empty-map shape.
83
- def invoke(target, method, args, kwargs)
84
- if kwargs.empty?
85
- target.public_send(method.to_sym, *args)
86
- else
87
- target.public_send(method.to_sym, *args, **kwargs)
88
- end
89
- end
90
-
91
- # {docs/behavior.md B-16}[link:../../../docs/behavior.md] — An Kobako::Handle arriving as a positional or keyword
92
- # argument identifies a host-side object previously allocated by a prior
93
- # RPC's Handle wrap (B-14). Resolve it back to the Ruby object before
94
- # the dispatch reaches +public_send+. A Handle whose entry is the
95
- # +:disconnected+ sentinel (E-14) raises DisconnectedTargetError so
96
- # the dispatcher emits a Response.error with type="disconnected".
97
- def resolve_arg(value, handle_table)
98
- case value
99
- when Kobako::Handle
100
- require_live_object!(value.id, handle_table)
101
- else
102
- value
103
- end
104
- end
105
-
106
- # Resolve a Request target to the Ruby object the Server (or
107
- # HandleTable) holds. String targets go through the Server;
108
- # Handle targets (ext 0x01) go through the HandleTable.
109
- #
110
- # Target type is already validated by +RPC.decode_request+
111
- # before this method is reached, so no else-branch is needed here —
112
- # the wire layer is the system boundary that enforces the invariant.
113
- def resolve_target(target, server, handle_table)
114
- case target
115
- when String
116
- resolve_path(target, server)
117
- when Kobako::Handle
118
- resolve_handle(target, handle_table)
119
- end
120
- end
121
-
122
- def resolve_path(path, server)
123
- server.lookup(path)
124
- rescue KeyError => e
125
- raise UndefinedTargetError, e.message
126
- end
127
-
128
- def resolve_handle(handle, handle_table)
129
- require_live_object!(handle.id, handle_table)
130
- end
131
-
132
- # Resolve +id+ through the HandleTable, distinguishing the
133
- # +:disconnected+ sentinel (E-14) from an unknown id (E-13).
134
- def require_live_object!(id, handle_table)
135
- object = handle_table.fetch(id)
136
- raise DisconnectedTargetError, "Handle id #{id} is disconnected" if object == :disconnected
137
-
138
- object
139
- rescue Kobako::HandleTableError => e
140
- raise UndefinedTargetError, e.message
141
- end
142
-
143
- # Encode +value+ as a +Response.ok+ envelope. When the value is not
144
- # wire-representable per {docs/behavior.md B-13}[link:../../../docs/behavior.md]'s type
145
- # mapping, the +UnsupportedType+ rescue routes it through the
146
- # HandleTable via {#wrap_as_handle} and re-encodes with the Capability
147
- # Handle in place ({docs/behavior.md B-14}[link:../../../docs/behavior.md]). The happy
148
- # path encodes exactly once.
149
- def encode_ok(value, handle_table)
150
- response = Kobako::RPC::Response.ok(value)
151
- Kobako::RPC.encode_response(response)
152
- rescue Kobako::Codec::UnsupportedType
153
- encode_ok(wrap_as_handle(value, handle_table), handle_table)
154
- end
155
-
156
- # Allocate +value+ in the Sandbox's HandleTable and return a +Handle+
157
- # that the wire codec can carry ({docs/behavior.md B-14}[link:../../../docs/behavior.md]).
158
- # Used as the fallback path of {#encode_ok} when +value+ has no wire
159
- # representation.
160
- def wrap_as_handle(value, handle_table)
161
- handle_table.alloc(value)
162
- end
163
-
164
- def encode_error(type, message)
165
- fault = Kobako::RPC::Fault.new(type: type, message: message)
166
- response = Kobako::RPC::Response.error(fault)
167
- Kobako::RPC.encode_response(response)
168
- end
169
- end
170
- end
171
- end
@@ -1,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../handle"
4
- require_relative "fault"
5
- require_relative "../codec"
6
-
7
- module Kobako
8
- # See lib/kobako/rpc.rb for the umbrella module doc; this file owns the
9
- # Request / Response value objects and their encode/decode helpers.
10
- module RPC
11
- # ---------------- Response status bytes (docs/wire-contract.md § Response Shape) ---
12
-
13
- # Response variant marker for the success branch.
14
- STATUS_OK = 0
15
- # Response variant marker for the fault branch.
16
- STATUS_ERROR = 1
17
-
18
- # Value object for a single guest-initiated RPC Request
19
- # ({docs/wire-codec.md Envelope Encoding → Request}[link:../../../docs/wire-codec.md]).
20
- #
21
- # 4-element msgpack array: +[target, method, args, kwargs]+. +target+
22
- # is either a +String+ (+"Namespace::Member"+) or a {Handle}. SPEC pins
23
- # +kwargs+ map keys to ext 0x00 Symbol; enforced at construction so the
24
- # Value Object is the single source of truth.
25
- Request = Data.define(:target, :method_name, :args, :kwargs) do
26
- # steep:ignore:start
27
- def initialize(target:, method:, args: [], kwargs: {})
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
- end
31
- raise ArgumentError, "Request method must be String" unless method.is_a?(String)
32
- raise ArgumentError, "Request args must be Array" unless args.is_a?(Array)
33
-
34
- validate_kwargs!(kwargs)
35
- super(target: target, method_name: method, args: args, kwargs: kwargs)
36
- end
37
-
38
- private
39
-
40
- def validate_kwargs!(kwargs)
41
- raise ArgumentError, "Request kwargs must be Hash" unless kwargs.is_a?(Hash)
42
-
43
- kwargs.each_key do |k|
44
- raise ArgumentError, "Request kwargs keys must be Symbol, got #{k.class}" unless k.is_a?(Symbol)
45
- end
46
- end
47
- # steep:ignore:end
48
- end
49
-
50
- # Encode a {Request} to msgpack bytes. The Value Object's own
51
- # invariants are the contract; this method does not re-check the shape.
52
- def self.encode_request(request)
53
- Codec::Encoder.encode([request.target, request.method_name, request.args, request.kwargs])
54
- end
55
-
56
- def self.decode_request(bytes)
57
- arr = Codec::Decoder.decode(bytes)
58
- unless arr.is_a?(Array) && arr.length == 4
59
- raise Codec::InvalidType, "Request envelope is malformed (expected a 4-element array)"
60
- end
61
-
62
- target, method_name, args, kwargs = arr
63
- Codec::Utils.wire_boundary do
64
- Request.new(target: target, method: method_name, args: args, kwargs: kwargs)
65
- end
66
- end
67
-
68
- # Value object for a single host-side RPC Response
69
- # ({docs/wire-codec.md Envelope Encoding → Response}[link:../../../docs/wire-codec.md]).
70
- #
71
- # 2-element msgpack array: +[status, value-or-fault]+. +status+ is 0
72
- # (success) or 1 (fault). For success the second element is the return
73
- # value; for fault it is a {Fault} (ext 0x02 envelope).
74
- Response = Data.define(:status, :payload) do
75
- # steep:ignore:start
76
- def self.ok(value)
77
- new(status: STATUS_OK, payload: value)
78
- end
79
-
80
- def self.error(fault)
81
- unless fault.is_a?(Kobako::RPC::Fault)
82
- raise ArgumentError, "Response.error requires Kobako::RPC::Fault, got #{fault.class}"
83
- end
84
-
85
- new(status: STATUS_ERROR, payload: fault)
86
- end
87
-
88
- def initialize(status:, payload:)
89
- unless [STATUS_OK, STATUS_ERROR].include?(status)
90
- raise ArgumentError, "Response status must be 0 (ok) or 1 (error), got #{status.inspect}"
91
- end
92
- if status == STATUS_ERROR && !payload.is_a?(Kobako::RPC::Fault)
93
- raise ArgumentError, "Response with error status must carry a Kobako::RPC::Fault payload"
94
- end
95
-
96
- super
97
- end
98
-
99
- def ok? = status == STATUS_OK
100
- def error? = status == STATUS_ERROR
101
- # steep:ignore:end
102
- end
103
-
104
- def self.encode_response(response)
105
- Codec::Encoder.encode([response.status, response.payload])
106
- end
107
-
108
- def self.decode_response(bytes)
109
- arr = Codec::Decoder.decode(bytes)
110
- unless arr.is_a?(Array) && arr.length == 2
111
- raise Codec::InvalidType, "Response envelope is malformed (expected a 2-element array)"
112
- end
113
-
114
- status, payload = arr
115
- Codec::Utils.wire_boundary { Response.new(status: status, payload: payload) }
116
- end
117
- end
118
- end