kobako 0.14.0 → 0.16.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +67 -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 +14 -0
  9. data/crates/kobako-runtime/Cargo.toml +1 -1
  10. data/crates/kobako-runtime/README.md +1 -1
  11. data/crates/kobako-runtime/src/snapshot.rs +7 -3
  12. data/crates/kobako-wasmtime/CHANGELOG.md +14 -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/extensions.rb +114 -0
  25. data/lib/kobako/catalog/handles.rb +1 -1
  26. data/lib/kobako/catalog/services.rb +135 -0
  27. data/lib/kobako/catalog/snippets.rb +1 -1
  28. data/lib/kobako/catalog.rb +7 -5
  29. data/lib/kobako/codec/decoder.rb +3 -3
  30. data/lib/kobako/codec/encoder.rb +4 -4
  31. data/lib/kobako/codec/ext_types.rb +169 -0
  32. data/lib/kobako/codec/state.rb +98 -0
  33. data/lib/kobako/codec/utils.rb +2 -2
  34. data/lib/kobako/codec.rb +23 -6
  35. data/lib/kobako/extension.rb +47 -0
  36. data/lib/kobako/fault.rb +1 -1
  37. data/lib/kobako/handle.rb +1 -1
  38. data/lib/kobako/outcome.rb +13 -7
  39. data/lib/kobako/pool.rb +1 -1
  40. data/lib/kobako/sandbox.rb +47 -27
  41. data/lib/kobako/transport/dispatcher.rb +25 -18
  42. data/lib/kobako/transport/request.rb +14 -9
  43. data/lib/kobako/transport/response.rb +9 -2
  44. data/lib/kobako/transport/yield.rb +4 -1
  45. data/lib/kobako/transport/yielder.rb +14 -6
  46. data/lib/kobako/version.rb +1 -1
  47. data/lib/kobako.rb +1 -0
  48. data/sig/kobako/catalog/extensions.rbs +25 -0
  49. data/sig/kobako/catalog/services.rbs +27 -0
  50. data/sig/kobako/codec/ext_types.rbs +31 -0
  51. data/sig/kobako/codec/state.rbs +20 -0
  52. data/sig/kobako/codec.rbs +3 -0
  53. data/sig/kobako/extension.rbs +20 -0
  54. data/sig/kobako/sandbox.rbs +3 -1
  55. data/sig/kobako/transport/dispatcher.rbs +4 -4
  56. data/sig/kobako/transport/yielder.rbs +1 -1
  57. data/sig/kobako/transport.rbs +2 -2
  58. metadata +11 -7
  59. data/lib/kobako/catalog/namespaces.rb +0 -115
  60. data/lib/kobako/codec/factory.rb +0 -187
  61. data/lib/kobako/namespace.rb +0 -78
  62. data/sig/kobako/catalog/namespaces.rbs +0 -17
  63. data/sig/kobako/codec/factory.rbs +0 -34
  64. data/sig/kobako/namespace.rbs +0 -21
@@ -18,11 +18,11 @@ use kobako_runtime::profile::Profile;
18
18
 
19
19
  /// Return the resolved `memory` export handle, or a `Trap` when the loaded
20
20
  /// module exports no linear memory — the "not a Kobako-shaped runtime"
21
- /// failure mode (`SANDBOX_RUNTIME_NOT_KOBAKO`).
21
+ /// failure mode (`guest_mem::SANDBOX_RUNTIME_NOT_KOBAKO`).
22
22
  fn require_memory(exports: &Exports) -> Result<Memory, Trap> {
23
23
  exports
24
24
  .memory
25
- .ok_or_else(|| Trap::Other(SANDBOX_RUNTIME_NOT_KOBAKO.to_string()))
25
+ .ok_or_else(|| Trap::Other(guest_mem::SANDBOX_RUNTIME_NOT_KOBAKO.to_string()))
26
26
  }
27
27
 
28
28
  /// Allocate a `len`-byte buffer in guest linear memory via
@@ -160,13 +160,6 @@ pub(crate) fn fetch_outcome_bytes(
160
160
  const SANDBOX_RUNTIME_MISSING_HOOKS: &str = "Sandbox runtime is missing required hooks; \
161
161
  rebuild data/kobako.wasm against the installed version";
162
162
 
163
- /// User-facing message for the "the loaded Wasm module is not a
164
- /// Kobako-shaped runtime at all" failure mode (no linear memory
165
- /// export). Same phrasing philosophy as
166
- /// `SANDBOX_RUNTIME_MISSING_HOOKS`.
167
- const SANDBOX_RUNTIME_NOT_KOBAKO: &str =
168
- "the loaded Wasm module is not a Kobako-compatible runtime";
169
-
170
163
  /// Return the resolved `TypedFunc` for an ABI export, or a `Trap`
171
164
  /// (boundary → `Kobako::TrapError`) when the option is `None`. Both
172
165
  /// run-path methods (`#eval`, `#run`) plus the `build_snapshot` readout
@@ -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.16.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
 
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Catalog
5
+ # Kobako::Catalog::Extensions — per-Sandbox registry of installed
6
+ # Extensions. Composes each Extension onto the sibling registries at
7
+ # install time (its +source+ into +Catalog::Snippets+, its +backend+
8
+ # path into +Catalog::Services+), asserts declared dependencies are
9
+ # present when the Sandbox seals, and resolves each callable-backed
10
+ # path to a fresh object at the start of every invocation.
11
+ #
12
+ # Sealing and the reject-after-install guard are governed by the owning
13
+ # Sandbox through +Catalog::Services#sealed?+, the shared seal signal;
14
+ # this registry only records entries and enforces the Extension-shape
15
+ # and dependency rules.
16
+ class Extensions
17
+ def initialize
18
+ @entries = [] # : Array[untyped]
19
+ @asserted = false
20
+ end
21
+
22
+ # Install +extension+: validate its shape, register its +source+ as a
23
+ # preloaded snippet, and — when it carries a +backend+ — reserve the
24
+ # backend path in +services+. A callable provider reserves the path
25
+ # with a placeholder the per-invocation refresh replaces; a fixed
26
+ # provider binds its object directly. Raises +ArgumentError+ on a
27
+ # malformed Extension; a malformed +name+ or +backend.path+ surfaces
28
+ # through the +snippets+ / +services+ registration it routes to.
29
+ def install(extension, snippets:, services:)
30
+ validate!(extension)
31
+ snippets.register(code: extension.source, name: extension.name)
32
+ backend = extension.backend
33
+ services.bind(backend.path, initial_object(backend.provider)) if backend
34
+ @entries << extension
35
+ self
36
+ end
37
+
38
+ # Assert every installed Extension's +depends_on+ names a fellow
39
+ # installed Extension. Runs once, when the Sandbox first seals; an
40
+ # unmet dependency raises +ArgumentError+ naming the gap, before the
41
+ # guest runs. Idempotent across later invocations.
42
+ def seal!
43
+ return self if @asserted
44
+
45
+ @asserted = true
46
+ assert_dependencies!
47
+ self
48
+ end
49
+
50
+ # Resolve each callable-backed path to this invocation's object and
51
+ # refresh it behind its already-sealed path in +services+. Distinct
52
+ # providers yield distinct objects; one provider shared by several
53
+ # Extensions is invoked once and its result shared, so provider
54
+ # identity is resource identity. Fixed providers are left untouched —
55
+ # they stay the object bound at install.
56
+ def refresh_backends!(services)
57
+ resolved = {} # : Hash[untyped, untyped]
58
+ resolved.compare_by_identity
59
+ @entries.each { |extension| refresh_backend(extension, services, resolved) }
60
+ self
61
+ end
62
+
63
+ private
64
+
65
+ # Resolve one Extension's callable-backed path against the shared
66
+ # per-invocation +resolved+ cache (keyed by provider identity) and
67
+ # refresh it in +services+. A fixed provider is skipped — its object
68
+ # stays as bound at install.
69
+ def refresh_backend(extension, services, resolved)
70
+ backend = extension.backend
71
+ return unless backend
72
+
73
+ provider = backend.provider
74
+ return unless callable?(provider)
75
+
76
+ object = resolved.fetch(provider) { resolved[provider] = provider.call }
77
+ services.refresh(backend.path, object)
78
+ end
79
+
80
+ # Enforce the Extension-shape checks +#preload+ / +#bind+ do not: a
81
+ # mandatory String +source+ (the install/bind boundary) and a
82
+ # +backend+ that, when present, exposes +path+ and +provider+.
83
+ def validate!(extension)
84
+ source = extension.source
85
+ raise ArgumentError, "Extension #source must be a String, got #{source.class}" unless source.is_a?(String)
86
+
87
+ backend = extension.backend
88
+ return if backend.nil?
89
+ return if backend.respond_to?(:path) && backend.respond_to?(:provider)
90
+
91
+ raise ArgumentError, "Extension #backend must expose #path and #provider"
92
+ end
93
+
94
+ def assert_dependencies!
95
+ names = @entries.map(&:name)
96
+ @entries.each do |extension|
97
+ (extension.depends_on || []).each do |dependency|
98
+ next if names.include?(dependency)
99
+
100
+ raise ArgumentError,
101
+ "Extension #{extension.name.inspect} depends on #{dependency.inspect}, which is not installed"
102
+ end
103
+ end
104
+ end
105
+
106
+ def callable?(provider) = provider.respond_to?(:call)
107
+
108
+ # A callable provider reserves its path with a placeholder that the
109
+ # per-invocation refresh replaces before any dispatch; a fixed
110
+ # provider is the bound object itself.
111
+ def initial_object(provider) = callable?(provider) ? nil : provider
112
+ end
113
+ end
114
+ end
@@ -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,135 @@
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
+ # Replace the object bound at an already-registered +path+ without the
69
+ # collision or seal checks +#bind+ applies. +Catalog::Extensions+ calls
70
+ # this during +begin_invocation!+ to swap a callable backend provider's
71
+ # per-invocation result behind a path whose Frame 1 membership was
72
+ # fixed at the seal. A path that was never registered is left untouched,
73
+ # so the sealed key set can never grow here.
74
+ def refresh(path, object)
75
+ key = path.to_s
76
+ @bindings[key] = object if @bindings.key?(key)
77
+ self
78
+ end
79
+
80
+ # Encode the preamble as msgpack bytes for stdin Frame 1 delivery —
81
+ # a flat array of the bound constant paths, in bind order:
82
+ # +["MyService::KV", "File"]+. Routes through Kobako::Codec::Encoder
83
+ # like every other host-side wire encode; the preamble carries only
84
+ # Strings, so none of the kobako ext types fire. Returns a binary
85
+ # +String+ of msgpack bytes.
86
+ #
87
+ # Once sealed, the bytes are computed once and reused for every
88
+ # subsequent invocation: sealing freezes Service registration at the
89
+ # first invocation, so a bind reaching the registry after the seal
90
+ # raises +ArgumentError+ and never alters Frame 1.
91
+ def encode
92
+ return @encoded if @encoded
93
+
94
+ bytes = Codec::Encoder.encode(@bindings.keys).freeze
95
+ @encoded = bytes if @sealed
96
+ bytes
97
+ end
98
+
99
+ # Mark the registry as sealed. Called by +Sandbox+ on the first
100
+ # invocation; afterwards #bind raises ArgumentError. Idempotent;
101
+ # returns +self+.
102
+ def seal!
103
+ @sealed = true
104
+ self
105
+ end
106
+
107
+ # Returns +true+ when #seal! has been called, +false+ otherwise.
108
+ def sealed?
109
+ @sealed
110
+ end
111
+
112
+ private
113
+
114
+ def validate_path!(path)
115
+ path_str = path.to_s
116
+ segments = path_str.split("::", -1)
117
+ return path_str if !segments.empty? && segments.all? { |seg| NAME_PATTERN.match?(seg) }
118
+
119
+ raise ArgumentError,
120
+ "bind path must be constant-form segments joined by '::' (got #{path.inspect})"
121
+ end
122
+
123
+ # A path collides when it equals, is a prefix of, or extends an
124
+ # existing binding on the +::+ segment boundary — the guardrail that
125
+ # keeps a name from being both a bound Service and a grouping prefix.
126
+ def collision?(path)
127
+ @bindings.each_key.any? do |existing|
128
+ existing == path ||
129
+ existing.start_with?("#{path}::") ||
130
+ path.start_with?("#{existing}::")
131
+ end
132
+ end
133
+ end
134
+ end
135
+ 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,15 +1,17 @@
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
+ require_relative "catalog/extensions"
6
7
 
7
8
  module Kobako
8
9
  # Kobako::Catalog — Sandbox-level configuration and per-invocation
9
- # allocation tables. Houses the three host-side registries the Sandbox
10
- # owns: +Catalog::Namespaces+ (Namespace / Member registry),
11
- # +Catalog::Snippets+ (preloaded source / bytecode entries), and
12
- # +Catalog::Handles+ (per-invocation Handle ID allocator).
10
+ # allocation tables. Houses the host-side registries the Sandbox owns:
11
+ # +Catalog::Services+ (path→Service binding registry),
12
+ # +Catalog::Snippets+ (preloaded source / bytecode entries),
13
+ # +Catalog::Extensions+ (installed Extensions, composed onto Services /
14
+ # Snippets), and +Catalog::Handles+ (per-invocation Handle ID allocator).
13
15
  #
14
16
  # See {SPEC.md Refinement → Internal Concepts}[link:../../SPEC.md] for
15
17
  # how Catalog fits alongside Transport and Runtime.
@@ -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