kobako 0.14.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +51 -0
  4. data/Cargo.lock +3 -3
  5. data/README.md +104 -29
  6. data/ROADMAP.md +2 -3
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +7 -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/snapshot.rs +7 -3
  12. data/crates/kobako-wasmtime/CHANGELOG.md +7 -0
  13. data/crates/kobako-wasmtime/Cargo.toml +2 -2
  14. data/crates/kobako-wasmtime/README.md +1 -1
  15. data/crates/kobako-wasmtime/src/capture.rs +17 -13
  16. data/crates/kobako-wasmtime/src/driver.rs +6 -7
  17. data/crates/kobako-wasmtime/src/frames.rs +2 -9
  18. data/crates/kobako-wasmtime/src/guest_mem.rs +8 -1
  19. data/crates/kobako-wasmtime/src/trap.rs +30 -57
  20. data/data/kobako.wasm +0 -0
  21. data/ext/kobako/Cargo.toml +1 -1
  22. data/ext/kobako/src/runtime/errors.rs +9 -39
  23. data/ext/kobako/src/runtime.rs +5 -20
  24. data/lib/kobako/catalog/handles.rb +1 -1
  25. data/lib/kobako/catalog/services.rb +123 -0
  26. data/lib/kobako/catalog/snippets.rb +1 -1
  27. data/lib/kobako/catalog.rb +2 -2
  28. data/lib/kobako/codec/decoder.rb +3 -3
  29. data/lib/kobako/codec/encoder.rb +4 -4
  30. data/lib/kobako/codec/ext_types.rb +169 -0
  31. data/lib/kobako/codec/state.rb +98 -0
  32. data/lib/kobako/codec/utils.rb +2 -2
  33. data/lib/kobako/codec.rb +23 -6
  34. data/lib/kobako/fault.rb +1 -1
  35. data/lib/kobako/handle.rb +1 -1
  36. data/lib/kobako/outcome.rb +13 -7
  37. data/lib/kobako/pool.rb +1 -1
  38. data/lib/kobako/sandbox.rb +20 -13
  39. data/lib/kobako/transport/dispatcher.rb +25 -18
  40. data/lib/kobako/transport/request.rb +14 -9
  41. data/lib/kobako/transport/response.rb +9 -2
  42. data/lib/kobako/transport/yield.rb +4 -1
  43. data/lib/kobako/transport/yielder.rb +14 -6
  44. data/lib/kobako/version.rb +1 -1
  45. data/sig/kobako/catalog/services.rbs +25 -0
  46. data/sig/kobako/codec/ext_types.rbs +31 -0
  47. data/sig/kobako/codec/state.rbs +20 -0
  48. data/sig/kobako/codec.rbs +3 -0
  49. data/sig/kobako/sandbox.rbs +1 -1
  50. data/sig/kobako/transport/dispatcher.rbs +4 -4
  51. data/sig/kobako/transport/yielder.rbs +1 -1
  52. data/sig/kobako/transport.rbs +2 -2
  53. metadata +7 -7
  54. data/lib/kobako/catalog/namespaces.rb +0 -115
  55. data/lib/kobako/codec/factory.rb +0 -187
  56. data/lib/kobako/namespace.rb +0 -78
  57. data/sig/kobako/catalog/namespaces.rbs +0 -17
  58. data/sig/kobako/codec/factory.rbs +0 -34
  59. data/sig/kobako/namespace.rbs +0 -21
@@ -44,6 +44,13 @@ impl Yielder for CallerYielder<'_, '_> {
44
44
  const RUNTIME_INCOMPATIBLE: &str =
45
45
  "the Sandbox runtime is incompatible; rebuild data/kobako.wasm against the installed version";
46
46
 
47
+ /// User-facing message for the "the loaded Wasm module is not a
48
+ /// Kobako-shaped runtime at all" failure mode — no linear memory export
49
+ /// here, no `memory` module export on the instantiation path in
50
+ /// `frames`. One constant so the two detection sites cannot drift.
51
+ pub(crate) const SANDBOX_RUNTIME_NOT_KOBAKO: &str =
52
+ "the loaded Wasm module is not a Kobako-compatible runtime";
53
+
47
54
  /// Resolve the guest's exported linear `memory`. The lookup shape (and its
48
55
  /// diagnostic) is shared by every Caller-based path here — the write side
49
56
  /// (`alloc_and_write`), the read side (`read`), and the yield round-trip
@@ -52,7 +59,7 @@ const RUNTIME_INCOMPATIBLE: &str =
52
59
  fn memory_export(caller: &mut Caller<'_, Invocation>) -> Result<Memory, &'static str> {
53
60
  match caller.get_export("memory") {
54
61
  Some(Extern::Memory(m)) => Ok(m),
55
- _ => Err("the loaded Wasm module is not a Kobako-compatible runtime"),
62
+ _ => Err(SANDBOX_RUNTIME_NOT_KOBAKO),
56
63
  }
57
64
  }
58
65
 
@@ -42,60 +42,29 @@ pub(crate) fn epoch_deadline_callback(
42
42
  }
43
43
  }
44
44
 
45
- /// Configured-cap path classification for a wasmtime error. The
46
- /// downcast logic stays in a pure helper so the `Trap` kind routing
47
- /// can be exercised from `cargo test` without any frontend.
48
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
49
- enum TrapClass {
50
- /// Wall-clock cap path.
51
- Timeout,
52
- /// Linear-memory cap path.
53
- MemoryLimit,
54
- /// Any other wasmtime error — surfaces as the base `Trap::Other`.
55
- Other,
56
- }
57
-
58
- /// Inspect a wasmtime error to decide which neutral trap kind it
59
- /// belongs to. Pure function — operates on the error's downcast chain
60
- /// only, no frontend state required.
61
- fn classify_trap(err: &wasmtime::Error) -> TrapClass {
62
- if err.downcast_ref::<TimeoutTrap>().is_some() {
63
- TrapClass::Timeout
64
- } else if err.downcast_ref::<MemoryLimitTrap>().is_some() {
65
- TrapClass::MemoryLimit
66
- } else {
67
- TrapClass::Other
68
- }
69
- }
70
-
71
- /// Classify a wasmtime call error into a neutral `Trap`. The ABI export
72
- /// symbol (`__kobako_eval` / `__kobako_run`) is deliberately omitted from
73
- /// the message — the Sandbox layer attaches the user-facing verb
45
+ /// Classify a wasmtime call error into a neutral `Trap`. Pure function
46
+ /// over the error's downcast chain, so the kind routing is exercisable
47
+ /// from `cargo test` without any frontend. The ABI export symbol
48
+ /// (`__kobako_eval` / `__kobako_run`) is deliberately omitted from the
49
+ /// message — the Sandbox layer attaches the user-facing verb
74
50
  /// (`Sandbox#eval` / `Sandbox#run`) so the message reads in caller
75
51
  /// vocabulary rather than ABI vocabulary.
76
52
  ///
77
- /// For the configured-cap paths (`TrapClass::Timeout` /
78
- /// `TrapClass::MemoryLimit`) the trap's own `std::fmt::Display`
53
+ /// For the configured-cap paths the trap's own `std::fmt::Display`
79
54
  /// carries the user-facing reason (`"wall-clock deadline exceeded"`,
80
- /// `"linear memory growth exceeded memory_limit: ..."`). The wasmtime
81
- /// outer wrapper at `format!("{err}")` would otherwise surface only
82
- /// the `"error while executing at wasm backtrace: ..."` framing, which
83
- /// is operator noise on a cap trap. For `TrapClass::Other` the framing
84
- /// is kept but the chain's root cause is appended (see
85
- /// `other_trap_message`) so the real trap reason survives.
55
+ /// `"linear memory growth exceeded memory_limit: ..."`); the wasmtime
56
+ /// outer wrapper would otherwise surface only the `"error while
57
+ /// executing at wasm backtrace: ..."` framing, which is operator noise
58
+ /// on a cap trap. For any other error the framing is kept but the
59
+ /// chain's root cause is appended (see `other_trap_message`) so the
60
+ /// real trap reason survives.
86
61
  pub(crate) fn trap_from(err: wasmtime::Error) -> Trap {
87
- match classify_trap(&err) {
88
- TrapClass::Timeout => Trap::Timeout(
89
- err.downcast_ref::<TimeoutTrap>()
90
- .map(|t| t.to_string())
91
- .unwrap_or_else(|| format!("{err}")),
92
- ),
93
- TrapClass::MemoryLimit => Trap::MemoryLimit(
94
- err.downcast_ref::<MemoryLimitTrap>()
95
- .map(|t| t.to_string())
96
- .unwrap_or_else(|| format!("{err}")),
97
- ),
98
- TrapClass::Other => Trap::Other(other_trap_message(&err)),
62
+ if let Some(t) = err.downcast_ref::<TimeoutTrap>() {
63
+ Trap::Timeout(t.to_string())
64
+ } else if let Some(t) = err.downcast_ref::<MemoryLimitTrap>() {
65
+ Trap::MemoryLimit(t.to_string())
66
+ } else {
67
+ Trap::Other(other_trap_message(&err))
99
68
  }
100
69
  }
101
70
 
@@ -129,8 +98,9 @@ pub(crate) fn instantiate_err(err: wasmtime::Error) -> SetupError {
129
98
 
130
99
  #[cfg(test)]
131
100
  mod tests {
132
- use super::{classify_trap, other_trap_message, TrapClass, NO_TIMEOUT_EPOCH_DELTA};
101
+ use super::{other_trap_message, trap_from, NO_TIMEOUT_EPOCH_DELTA};
133
102
  use crate::invocation::{Invocation, MemoryLimitTrap, TimeoutTrap};
103
+ use kobako_runtime::error::Trap;
134
104
 
135
105
  // The no-timeout priming delta is added to the engine's current
136
106
  // epoch inside wasmtime, and the process-wide ticker advances that
@@ -148,21 +118,24 @@ mod tests {
148
118
  }
149
119
 
150
120
  #[test]
151
- fn classify_trap_routes_timeout_trap_to_timeout() {
121
+ fn trap_from_routes_timeout_trap_to_timeout() {
152
122
  let err = wasmtime::Error::new(TimeoutTrap);
153
- assert_eq!(classify_trap(&err), TrapClass::Timeout);
123
+ let expected = TimeoutTrap.to_string();
124
+ assert!(matches!(trap_from(err), Trap::Timeout(msg) if msg == expected));
154
125
  }
155
126
 
156
127
  #[test]
157
- fn classify_trap_routes_memory_limit_trap_to_memory_limit() {
158
- let err = wasmtime::Error::new(MemoryLimitTrap::new(1 << 20, 1 << 19));
159
- assert_eq!(classify_trap(&err), TrapClass::MemoryLimit);
128
+ fn trap_from_routes_memory_limit_trap_to_memory_limit() {
129
+ let trap = MemoryLimitTrap::new(1 << 20, 1 << 19);
130
+ let expected = trap.to_string();
131
+ let err = wasmtime::Error::new(trap);
132
+ assert!(matches!(trap_from(err), Trap::MemoryLimit(msg) if msg == expected));
160
133
  }
161
134
 
162
135
  #[test]
163
- fn classify_trap_falls_back_to_other_for_unknown_errors() {
136
+ fn trap_from_falls_back_to_other_for_unknown_errors() {
164
137
  let err = wasmtime::Error::msg("some other wasmtime fault");
165
- assert_eq!(classify_trap(&err), TrapClass::Other);
138
+ assert!(matches!(trap_from(err), Trap::Other(_)));
166
139
  }
167
140
 
168
141
  // A guest hard trap reaches the host as a wasmtime error whose Display is
data/data/kobako.wasm CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.14.0"
3
+ version = "0.15.0"
4
4
  edition = "2021"
5
5
  authors = ["Aotokitsuruya <contact@aotoki.me>"]
6
6
  license = "Apache-2.0"
@@ -55,48 +55,15 @@ pub(super) fn trap_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
55
55
  error_in(ruby, &TRAP_ERROR, msg)
56
56
  }
57
57
 
58
- /// Construct a `Kobako::SetupError` magnus error. Used for every
59
- /// construction-time failure on the `Runtime.from_path` path before any
60
- /// invocation runs — unreadable artifact, bytes that are not a valid Wasm
61
- /// module, or engine / linker / instantiation setup failure. The
62
- /// `ModuleNotBuiltError` subclass (artifact absent) is
63
- /// raised through `MODULE_NOT_BUILT_ERROR` directly.
64
- pub(super) fn setup_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
65
- error_in(ruby, &SETUP_ERROR, msg)
66
- }
67
-
68
- /// Construct a `Kobako::TimeoutError` magnus error. Surfaces the
69
- /// wall-clock cap path with the verb prefix added
70
- /// by `Kobako::Sandbox#invoke!`.
71
- fn timeout_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
72
- error_in(ruby, &TIMEOUT_ERROR, msg)
73
- }
74
-
75
- /// Construct a `Kobako::MemoryLimitError` magnus error. Surfaces the
76
- /// linear-memory cap path with the verb prefix
77
- /// added by `Kobako::Sandbox#invoke!`.
78
- fn memory_limit_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
79
- error_in(ruby, &MEMORY_LIMIT_ERROR, msg)
80
- }
81
-
82
- /// Construct a `Kobako::SandboxError` magnus error. Used for the
83
- /// host-side pre-call faults the SPEC attributes to the sandbox / wire
84
- /// layer rather than the Wasm engine — currently the `#run` invocation
85
- /// envelope reservation failure (`__kobako_alloc` returns 0).
86
- /// The runtime is intact, so this must not be a
87
- /// `TrapError`: no discard-and-recreate recovery is owed to the caller.
88
- fn sandbox_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
89
- error_in(ruby, &SANDBOX_ERROR, msg)
90
- }
91
-
92
58
  /// Map a neutral `Trap` onto its `Kobako::TrapError`-family Ruby exception.
93
59
  /// The boundary between the magnus-free run mechanics and the Ruby surface:
94
60
  /// the run path classifies a fault into a `Trap`, and this is where it
95
- /// becomes a raised exception.
61
+ /// becomes a raised exception. The verb prefix (`Sandbox#eval` / `#run`)
62
+ /// is added by `Kobako::Sandbox#invoke!`.
96
63
  pub(super) fn trap_to_magnus(ruby: &Ruby, trap: Trap) -> MagnusError {
97
64
  match trap {
98
- Trap::Timeout(msg) => timeout_err(ruby, msg),
99
- Trap::MemoryLimit(msg) => memory_limit_err(ruby, msg),
65
+ Trap::Timeout(msg) => error_in(ruby, &TIMEOUT_ERROR, msg),
66
+ Trap::MemoryLimit(msg) => error_in(ruby, &MEMORY_LIMIT_ERROR, msg),
100
67
  Trap::Other(msg) => trap_err(ruby, msg),
101
68
  }
102
69
  }
@@ -106,8 +73,11 @@ pub(super) fn trap_to_magnus(ruby: &Ruby, trap: Trap) -> MagnusError {
106
73
  pub(super) fn setup_to_magnus(ruby: &Ruby, err: SetupError) -> MagnusError {
107
74
  match err {
108
75
  SetupError::ModuleNotBuilt(msg) => error_in(ruby, &MODULE_NOT_BUILT_ERROR, msg),
109
- SetupError::Dead(msg) => setup_err(ruby, msg),
110
- SetupError::Intact(msg) => sandbox_err(ruby, msg),
76
+ SetupError::Dead(msg) => error_in(ruby, &SETUP_ERROR, msg),
77
+ // Runtime intact means a host-side pre-call fault the SPEC
78
+ // attributes to the sandbox / wire layer, not the engine — no
79
+ // discard-and-recreate recovery is owed, so never a TrapError.
80
+ SetupError::Intact(msg) => error_in(ruby, &SANDBOX_ERROR, msg),
111
81
  }
112
82
  }
113
83
 
@@ -53,26 +53,14 @@ fn rstring_to_vec(s: RString) -> Vec<u8> {
53
53
  unsafe { s.as_slice() }.to_vec()
54
54
  }
55
55
 
56
- /// The pre-invocation sentinel for one capture channel: no bytes, cap
57
- /// not reached. Fresh `Vec`s per call because `Capture` owns its buffer.
58
- fn empty_capture() -> Capture {
59
- Capture {
60
- bytes: Vec::new(),
61
- truncated: false,
62
- }
63
- }
64
-
65
56
  // ---------------------------------------------------------------------------
66
57
  // Ruby init
67
58
  // ---------------------------------------------------------------------------
68
59
 
69
60
  pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
70
- // Error hierarchy lives in `lib/kobako/errors.rb` (top-level
71
- // `Kobako::TrapError` / `TimeoutError` / `MemoryLimitError` /
72
- // `SetupError` / `ModuleNotBuiltError`). The ext raises directly into
73
- // those classes through `trap_err` / `timeout_err` / `memory_limit_err`
74
- // / `sandbox_err` / `setup_err` / `MODULE_NOT_BUILT_ERROR`; no
75
- // intermediate hierarchy is registered.
61
+ // Error hierarchy lives in `lib/kobako/errors.rb`; the ext raises
62
+ // directly into those classes through the constructors and mappers
63
+ // in `runtime/errors.rs` no intermediate hierarchy is registered.
76
64
 
77
65
  let runtime = kobako.define_class("Runtime", ruby.class_object())?;
78
66
  runtime.define_singleton_method("from_path", function!(Runtime::from_path, 6))?;
@@ -210,11 +198,8 @@ impl Runtime {
210
198
  Ok(Self {
211
199
  driver,
212
200
  on_dispatch: Cell::new(None),
213
- last_usage: Cell::new(Usage {
214
- wall_time: 0.0,
215
- memory_peak: 0,
216
- }),
217
- last_captures: RefCell::new((empty_capture(), empty_capture())),
201
+ last_usage: Cell::new(Usage::default()),
202
+ last_captures: RefCell::new((Capture::default(), Capture::default())),
218
203
  })
219
204
  }
220
205
 
@@ -6,7 +6,7 @@ module Kobako
6
6
  module Catalog
7
7
  # Host-side mapping from opaque integer Handle IDs to Ruby objects.
8
8
  # The table is owned by +Kobako::Sandbox+ and injected
9
- # into the per-Sandbox +Kobako::Catalog::Namespaces+ so guest→host dispatch
9
+ # into the per-Sandbox +Kobako::Catalog::Services+ so guest→host dispatch
10
10
  # resolves Handle targets and arguments against the same table that
11
11
  # host→guest wire encoding allocates into.
12
12
  #
@@ -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
@@ -44,7 +44,7 @@ module Kobako
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
46
  # encodes; #register drops the memo while the table is still open.
47
- # Unlike +Catalog::Namespaces#encode+, which gates its memo on the
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
@@ -31,7 +31,7 @@ module Kobako
31
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+
@@ -54,7 +54,7 @@ module Kobako
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
56
  # str-typed leaf via Utils.assert_utf8!. Kobako::Fault
57
- # payloads are validated transitively: +Factory.unpack_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,8 +13,8 @@ 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
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
@@ -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
@@ -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