kobako 0.1.2 → 0.2.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +95 -60
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +1 -1
  6. data/ext/kobako/src/wasm/cache.rs +39 -1
  7. data/ext/kobako/src/wasm/dispatch.rs +20 -20
  8. data/ext/kobako/src/wasm/host_state.rs +261 -34
  9. data/ext/kobako/src/wasm/instance.rs +467 -272
  10. data/ext/kobako/src/wasm.rs +50 -19
  11. data/lib/kobako/capture.rb +46 -0
  12. data/lib/kobako/codec/decoder.rb +66 -0
  13. data/lib/kobako/codec/encoder.rb +37 -0
  14. data/lib/kobako/codec/error.rb +33 -0
  15. data/lib/kobako/codec/factory.rb +155 -0
  16. data/lib/kobako/codec/utils.rb +55 -0
  17. data/lib/kobako/codec.rb +27 -0
  18. data/lib/kobako/errors.rb +24 -1
  19. data/lib/kobako/outcome/panic.rb +42 -0
  20. data/lib/kobako/outcome.rb +133 -0
  21. data/lib/kobako/rpc/dispatcher.rb +169 -0
  22. data/lib/kobako/rpc/envelope.rb +118 -0
  23. data/lib/kobako/{wire/exception.rb → rpc/fault.rb} +6 -4
  24. data/lib/kobako/{wire → rpc}/handle.rb +4 -2
  25. data/lib/kobako/{registry → rpc}/handle_table.rb +9 -9
  26. data/lib/kobako/{registry/service_group.rb → rpc/namespace.rb} +20 -11
  27. data/lib/kobako/rpc/server.rb +156 -0
  28. data/lib/kobako/rpc.rb +11 -0
  29. data/lib/kobako/sandbox.rb +149 -69
  30. data/lib/kobako/version.rb +1 -1
  31. data/lib/kobako/wasm.rb +6 -16
  32. data/lib/kobako.rb +2 -0
  33. data/sig/kobako/capture.rbs +13 -0
  34. data/sig/kobako/codec/decoder.rbs +11 -0
  35. data/sig/kobako/codec/encoder.rbs +7 -0
  36. data/sig/kobako/codec/error.rbs +18 -0
  37. data/sig/kobako/codec/factory.rbs +31 -0
  38. data/sig/kobako/codec/utils.rbs +9 -0
  39. data/sig/kobako/errors.rbs +52 -0
  40. data/sig/kobako/outcome/panic.rbs +34 -0
  41. data/sig/kobako/outcome.rbs +24 -0
  42. data/sig/kobako/rpc/dispatcher.rbs +33 -0
  43. data/sig/kobako/rpc/envelope.rbs +51 -0
  44. data/sig/kobako/rpc/fault.rbs +20 -0
  45. data/sig/kobako/rpc/handle.rbs +19 -0
  46. data/sig/kobako/rpc/handle_table.rbs +25 -0
  47. data/sig/kobako/rpc/namespace.rbs +24 -0
  48. data/sig/kobako/rpc/server.rbs +37 -0
  49. data/sig/kobako/rpc.rbs +4 -0
  50. data/sig/kobako/sandbox.rbs +53 -0
  51. data/sig/kobako/wasm.rbs +37 -0
  52. data/sig/kobako.rbs +0 -1
  53. metadata +37 -17
  54. data/lib/kobako/registry/dispatcher.rb +0 -168
  55. data/lib/kobako/registry.rb +0 -160
  56. data/lib/kobako/sandbox/outcome_decoder.rb +0 -100
  57. data/lib/kobako/sandbox/output_buffer.rb +0 -79
  58. data/lib/kobako/wire/codec/decoder.rb +0 -87
  59. data/lib/kobako/wire/codec/encoder.rb +0 -41
  60. data/lib/kobako/wire/codec/error.rb +0 -35
  61. data/lib/kobako/wire/codec/factory.rb +0 -136
  62. data/lib/kobako/wire/codec.rb +0 -44
  63. data/lib/kobako/wire/envelope/payloads.rb +0 -145
  64. data/lib/kobako/wire/envelope.rb +0 -147
  65. data/lib/kobako/wire.rb +0 -40
@@ -4,22 +4,27 @@
4
4
  //
5
5
  // Kobako::Wasm::Instance — wraps wasmtime::Instance + cached TypedFuncs
6
6
  //
7
- // constructed via `Kobako::Wasm::Instance.from_path(path)`. The underlying
8
- // wasmtime Engine and compiled Module live in a process-scope cache (see
9
- // the `cache` submodule) and never surface to Ruby (SPEC.md "Code
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
10
11
  // Organization": `ext/` "exposes no Wasm engine types to the Host App or
11
12
  // downstream gems").
12
13
  //
13
14
  // Module layout (per CLAUDE.md principle #2 — one responsibility per file):
14
15
  //
15
- // * `cache` — process-wide Engine + per-path Module cache.
16
- // * `host_state` — HostState (per-Store context) + StoreCell wrapper.
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`]).
17
21
  // * `instance` — Kobako::Wasm::Instance and its run-path methods.
18
- // * `dispatch` — `__kobako_rpc_call` host-import dispatch helpers.
22
+ // * `dispatch` — `__kobako_dispatch` host-import dispatch helpers.
19
23
  //
20
24
  // This file is the façade: it owns the Ruby error class lazy-resolvers,
21
- // the `wasm_err` constructor shared by every submodule, and the Ruby
22
- // init() that registers `Kobako::Wasm::Instance` and its methods.
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.
23
28
 
24
29
  mod cache;
25
30
  mod dispatch;
@@ -47,10 +52,36 @@ pub(crate) static WASM_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
47
52
  wasm.const_get("Error").unwrap()
48
53
  });
49
54
 
55
+ pub(crate) static WASM_TIMEOUT_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
56
+ let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
57
+ let wasm: RModule = kobako.const_get("Wasm").unwrap();
58
+ wasm.const_get("TimeoutError").unwrap()
59
+ });
60
+
61
+ pub(crate) static WASM_MEMORY_LIMIT_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
62
+ let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
63
+ let wasm: RModule = kobako.const_get("Wasm").unwrap();
64
+ wasm.const_get("MemoryLimitError").unwrap()
65
+ });
66
+
50
67
  pub(crate) fn wasm_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
51
68
  MagnusError::new(ruby.get_inner(&WASM_ERROR), msg.into())
52
69
  }
53
70
 
71
+ /// Construct a `Kobako::Wasm::TimeoutError` magnus error. Surfaces the
72
+ /// SPEC.md E-19 wall-clock cap path so the Sandbox layer can rewrap it
73
+ /// as `Kobako::TimeoutError`.
74
+ pub(crate) fn timeout_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
75
+ MagnusError::new(ruby.get_inner(&WASM_TIMEOUT_ERROR), msg.into())
76
+ }
77
+
78
+ /// Construct a `Kobako::Wasm::MemoryLimitError` magnus error. Surfaces
79
+ /// the SPEC.md E-20 linear-memory cap path so the Sandbox layer can
80
+ /// rewrap it as `Kobako::MemoryLimitError`.
81
+ pub(crate) fn memory_limit_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
82
+ MagnusError::new(ruby.get_inner(&WASM_MEMORY_LIMIT_ERROR), msg.into())
83
+ }
84
+
54
85
  // ---------------------------------------------------------------------------
55
86
  // Ruby init
56
87
  // ---------------------------------------------------------------------------
@@ -60,21 +91,21 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
60
91
 
61
92
  // Error hierarchy. ModuleNotBuiltError is the headline error for the
62
93
  // common pre-build state where `data/kobako.wasm` has not yet been
63
- // produced (e.g. fresh clone before `rake compile`).
94
+ // produced (e.g. fresh clone before `rake compile`). TimeoutError and
95
+ // MemoryLimitError carry the SPEC.md B-01 per-run cap paths up to the
96
+ // Sandbox layer.
64
97
  let base_err = wasm.define_error("Error", ruby.exception_standard_error())?;
65
98
  wasm.define_error("ModuleNotBuiltError", base_err)?;
99
+ wasm.define_error("TimeoutError", base_err)?;
100
+ wasm.define_error("MemoryLimitError", base_err)?;
66
101
 
67
102
  let instance = wasm.define_class("Instance", ruby.class_object())?;
68
- instance.define_singleton_method("from_path", function!(Instance::from_path, 1))?;
69
- instance.define_method("alloc", method!(Instance::alloc, 1))?;
70
- instance.define_method("write_memory", method!(Instance::write_memory, 2))?;
71
- instance.define_method("read_memory", method!(Instance::read_memory, 2))?;
72
- instance.define_method("run", method!(Instance::run_call, 0))?;
73
- instance.define_method("take_outcome", method!(Instance::take_outcome, 0))?;
74
- instance.define_method("set_registry", method!(Instance::set_registry, 1))?;
75
- instance.define_method("setup_wasi_pipes", method!(Instance::setup_wasi_pipes, 4))?;
76
- instance.define_method("take_stdout", method!(Instance::take_stdout, 0))?;
77
- instance.define_method("take_stderr", method!(Instance::take_stderr, 0))?;
103
+ instance.define_singleton_method("from_path", function!(Instance::from_path, 5))?;
104
+ instance.define_method("server=", method!(Instance::set_server, 1))?;
105
+ instance.define_method("run", method!(Instance::run, 2))?;
106
+ instance.define_method("stdout", method!(Instance::stdout, 0))?;
107
+ instance.define_method("stderr", method!(Instance::stderr, 0))?;
108
+ instance.define_method("outcome!", method!(Instance::outcome, 0))?;
78
109
 
79
110
  Ok(())
80
111
  }
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ # Host-side captured prefix of guest stdout / stderr produced during a
5
+ # single +Kobako::Sandbox#run+, paired with the truncation flag the WASI
6
+ # pipe sets when the guest wrote past the configured per-channel cap
7
+ # ({SPEC.md B-04}[link:../../SPEC.md]).
8
+ #
9
+ # Immutable value object: the captured bytes and the truncation flag
10
+ # always travel together and the instance is frozen on construction.
11
+ # Construct via +Capture.from_ext+ for ext-provided binary bytes (handles
12
+ # UTF-8 / ASCII-8BIT fallback) or reach +Capture::EMPTY+ for the pre-run
13
+ # sentinel that +Sandbox+ uses before any +#run+ has executed.
14
+ class Capture
15
+ attr_reader :bytes
16
+
17
+ # Build a Capture wrapping +bytes+ (the captured prefix as a String) and
18
+ # +truncated+ (whether the originating WASI pipe reported the cap was
19
+ # hit). Freezes the instance so callers cannot mutate the pair.
20
+ def initialize(bytes:, truncated:)
21
+ @bytes = bytes
22
+ @truncated = truncated
23
+ freeze
24
+ end
25
+
26
+ # Returns +true+ iff the underlying capture channel exceeded its
27
+ # configured cap during the originating +Sandbox#run+
28
+ # ({SPEC.md B-04}[link:../../SPEC.md]).
29
+ def truncated? = @truncated
30
+
31
+ # Construct a Capture from ext-provided binary bytes. Coerces +bytes+
32
+ # to UTF-8 when the bytes are valid UTF-8, otherwise falls back to
33
+ # ASCII-8BIT so invalid sequences remain inspectable without raising.
34
+ # +bytes+ is not mutated.
35
+ def self.from_ext(bytes, truncated)
36
+ copy = bytes.dup.force_encoding(Encoding::UTF_8)
37
+ copy.force_encoding(Encoding::ASCII_8BIT) unless copy.valid_encoding?
38
+ new(bytes: copy, truncated: truncated)
39
+ end
40
+
41
+ # Pre-run sentinel ({SPEC.md B-05}[link:../../SPEC.md]). Empty UTF-8
42
+ # bytes and +truncated? == false+; reused by every fresh +Sandbox+ and
43
+ # by +Sandbox#run+ between invocations to denote "no capture yet".
44
+ EMPTY = new(bytes: "", truncated: false)
45
+ end
46
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "msgpack"
4
+
5
+ require_relative "error"
6
+ require_relative "factory"
7
+ require_relative "utils"
8
+
9
+ module Kobako
10
+ module Codec
11
+ # Module-level entry point for the host side of the kobako wire
12
+ # (SPEC.md → Wire Codec → Type Mapping).
13
+ #
14
+ # Translates msgpack gem exceptions into the kobako error taxonomy
15
+ # ({Truncated}, {InvalidType}, {InvalidEncoding}, {UnsupportedType}) so
16
+ # callers can pattern-match on the SPEC's wire-violation categories
17
+ # without leaking the gem's internal exception classes.
18
+ #
19
+ # Public API is a single function — {.decode}. The decoder is
20
+ # stateless; the +MessagePack::Unpacker+ instance is built per call
21
+ # because callers always decode exactly one wire value at a time.
22
+ module Decoder
23
+ # Decode +bytes+ into one Ruby value and validate transitively
24
+ # against the SPEC type mapping. Raises {Truncated}, {InvalidType},
25
+ # or {InvalidEncoding} on wire violations.
26
+ def self.decode(bytes)
27
+ value = Factory.load(bytes.b)
28
+ validate_utf8!(value)
29
+ value
30
+ # msgpack gem raises these for type/format violations; +ArgumentError+
31
+ # also comes from our ext-type validators (Handle id range, Exception
32
+ # type whitelist).
33
+ rescue ::MessagePack::UnknownExtTypeError, ::MessagePack::MalformedFormatError,
34
+ ::MessagePack::StackError, ::ArgumentError => e
35
+ raise InvalidType, e.message
36
+ # +UnpackError+ is the gem's umbrella class for short-read /
37
+ # incomplete-buffer faults; +EOFError+ covers underflow at the
38
+ # buffer edge.
39
+ rescue ::MessagePack::UnpackError, ::EOFError => e
40
+ raise Truncated, e.message
41
+ rescue ::EncodingError => e
42
+ raise InvalidEncoding, e.message
43
+ end
44
+
45
+ # SPEC pins +str+ family payloads to UTF-8 (Wire Codec → str/bin
46
+ # Encoding Rules). The msgpack gem returns UTF-8-tagged Strings for
47
+ # str family but does not validate the bytes; +bin+ family decodes
48
+ # to ASCII-8BIT. Walk the tree once and reject invalid UTF-8 in any
49
+ # str-typed leaf via {Utils.assert_utf8!}. {Kobako::RPC::Fault}
50
+ # payloads are validated transitively: +Factory.unpack_fault+
51
+ # feeds the inner ext-0x02 bytes back through this Decoder, so their
52
+ # +str+ fields are already covered by the time control returns here.
53
+ class << self
54
+ private
55
+
56
+ def validate_utf8!(value)
57
+ case value
58
+ when String then Utils.assert_utf8!(value, "str payload") if value.encoding == Encoding::UTF_8
59
+ when Array then value.each { |v| validate_utf8!(v) }
60
+ when Hash then value.each { |pair| validate_utf8!(pair) }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "msgpack"
4
+
5
+ require_relative "error"
6
+ require_relative "factory"
7
+
8
+ module Kobako
9
+ module Codec
10
+ # Module-level entry point for the host side of the kobako wire
11
+ # (SPEC.md → Wire Codec → Type Mapping).
12
+ #
13
+ # The codec backbone is the official +msgpack+ gem: integers, floats,
14
+ # strings, arrays, and maps go through the gem's narrowest-encoding
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.
18
+ #
19
+ # Public API is a single function — {.encode}. The codec is stateless;
20
+ # there is no buffer accumulator and no streaming write API. Callers
21
+ # that need to concatenate multiple encodings build the bytes
22
+ # themselves.
23
+ module Encoder
24
+ # Encode +value+ to wire bytes (binary-encoded String).
25
+ # Wire violations surface as +UnsupportedType+: SPEC's 12-entry type
26
+ # mapping is a closed set, and anything outside it is rejected by
27
+ # the msgpack gem itself (arbitrary objects raise +NoMethodError+
28
+ # from missing +to_msgpack+, integers outside i64..u64 raise
29
+ # +RangeError+).
30
+ def self.encode(value)
31
+ Factory.dump(value)
32
+ rescue ::RangeError, ::NoMethodError => e
33
+ raise UnsupportedType, e.message
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Codec
5
+ # Base class for all wire-codec faults raised by the pure-Ruby host codec.
6
+ #
7
+ # The wire codec implements the binary contract pinned in SPEC.md
8
+ # (Wire Codec → Type Mapping). Every wire violation surfaces as a
9
+ # subclass of {Error} so callers can pattern-match on the specific
10
+ # fault while still rescuing all codec faults via this base class.
11
+ #
12
+ # Higher layers (e.g. the Sandbox dispatch loop) translate these into
13
+ # the public {Kobako::SandboxError} / {Kobako::TrapError} taxonomy.
14
+ class Error < StandardError; end
15
+
16
+ # Input ended before the type prefix or payload was fully consumed.
17
+ class Truncated < Error; end
18
+
19
+ # The type byte at the current position is not in the 12-entry kobako
20
+ # type mapping (e.g. an unknown ext code, or a reserved msgpack tag).
21
+ class InvalidType < Error; end
22
+
23
+ # A msgpack `str` payload was not valid UTF-8, or an ext 0x00 Symbol
24
+ # payload was not valid UTF-8 — both are wire violations per SPEC.
25
+ class InvalidEncoding < Error; end
26
+
27
+ # The encoder was handed a Ruby object whose type has no wire
28
+ # representation (e.g. Range, Time). Higher layers may catch this
29
+ # and re-route the value through Handle allocation, but at the
30
+ # codec level it is a hard error.
31
+ class UnsupportedType < Error; end
32
+ end
33
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "forwardable"
5
+ require "msgpack"
6
+
7
+ require_relative "error"
8
+ require_relative "utils"
9
+ require_relative "../rpc/handle"
10
+ require_relative "../rpc/fault"
11
+
12
+ module Kobako
13
+ module Codec
14
+ # Cached +MessagePack::Factory+ that owns the kobako wire ext-type
15
+ # registration (SPEC.md → Wire Codec → Ext Types).
16
+ #
17
+ # The factory is the single place in the host gem that touches the
18
+ # msgpack API — both {Encoder} and {Decoder} delegate through it, so
19
+ # the three kobako ext codes (0x00 Symbol, 0x01 Capability Handle,
20
+ # 0x02 Exception envelope) are configured exactly once at first use.
21
+ #
22
+ # Lifecycle is owned by +Singleton+ from the Ruby standard library:
23
+ # +Factory.instance+ is lazy, thread-safe, and process-wide. Class-level
24
+ # +Factory.dump+ / +Factory.load+ shortcuts are exposed via
25
+ # +SingleForwardable+ so callers do not have to spell the +.instance+
26
+ # hop at every call site; the instance-level +#dump+ / +#load+ are in
27
+ # turn delegated to the wrapped +MessagePack::Factory+ via +Forwardable+.
28
+ class Factory
29
+ include Singleton
30
+ extend Forwardable
31
+ extend SingleForwardable
32
+
33
+ # MessagePack ext type code reserved for Symbol
34
+ # (SPEC.md → Wire Codec → Ext Types → ext 0x00). Class-private —
35
+ # mirrors +codec::EXT_SYMBOL+ on the Rust side.
36
+ EXT_SYMBOL = 0x00
37
+ # MessagePack ext type code reserved for Capability Handle
38
+ # (SPEC.md → Wire Codec → Ext Types → ext 0x01). Class-private —
39
+ # mirrors +codec::EXT_HANDLE+ on the Rust side.
40
+ EXT_HANDLE = 0x01
41
+ # MessagePack ext type code reserved for Exception envelope
42
+ # (SPEC.md → Wire Codec → Ext Types → ext 0x02). Class-private —
43
+ # mirrors +codec::EXT_ERRENV+ on the Rust side.
44
+ EXT_ERRENV = 0x02
45
+ private_constant :EXT_SYMBOL, :EXT_HANDLE, :EXT_ERRENV
46
+
47
+ # Instance-level pass-through onto the wrapped +MessagePack::Factory+.
48
+ # Spelled +def_instance_delegators+ rather than +def_delegators+ because
49
+ # the class also extends +SingleForwardable+ (see the +extend+ block
50
+ # above), which defines its own +def_delegators+ that shadows
51
+ # +Forwardable+'s — the unambiguous forms keep both delegation tiers
52
+ # wired to the right scope.
53
+ def_instance_delegators :@factory, :dump, :load
54
+
55
+ # Class-level shortcuts so callers can write +Factory.dump(v)+ instead
56
+ # of +Factory.instance.dump(v)+; both resolve to the same singleton.
57
+ def_single_delegators :instance, :dump, :load
58
+
59
+ def initialize
60
+ @factory = MessagePack::Factory.new
61
+ register_symbol
62
+ register_handle
63
+ register_fault
64
+ end
65
+
66
+ private
67
+
68
+ def register_symbol
69
+ @factory.register_type(
70
+ EXT_SYMBOL, Symbol,
71
+ packer: method(:pack_symbol),
72
+ unpacker: method(:unpack_symbol)
73
+ )
74
+ end
75
+
76
+ # Symbol-to-name packer — extracted to a real method so Steep can
77
+ # resolve the proc shape without tripping on +lambda(&:name)+'s
78
+ # +Symbol#to_proc+ inference path.
79
+ def pack_symbol(symbol)
80
+ symbol.name
81
+ end
82
+
83
+ # Validate the ext-0x00 payload as UTF-8 and intern. Raises
84
+ # {InvalidEncoding} on invalid bytes — SPEC forbids the
85
+ # binary-encoding fallback that msgpack-gem's default unpacker
86
+ # would otherwise apply. The re-tag step lives here because the
87
+ # msgpack ext-type unpacker hands us binary bytes; the assertion
88
+ # itself is shared with {Decoder} via {Utils.assert_utf8!}.
89
+ def unpack_symbol(payload)
90
+ name = payload.b.force_encoding(Encoding::UTF_8)
91
+ Utils.assert_utf8!(name, "ext 0x00 payload")
92
+ name.to_sym
93
+ end
94
+
95
+ def register_handle
96
+ @factory.register_type(
97
+ EXT_HANDLE, RPC::Handle,
98
+ packer: ->(handle) { [handle.id].pack("N") },
99
+ unpacker: ->(payload) { unpack_handle(payload) }
100
+ )
101
+ end
102
+
103
+ def register_fault
104
+ @factory.register_type(
105
+ EXT_ERRENV, RPC::Fault,
106
+ packer: ->(fault) { pack_fault(fault) },
107
+ unpacker: ->(payload) { unpack_fault(payload) }
108
+ )
109
+ end
110
+
111
+ # Peel off the fixext-4 frame, hand the bytes to +RPC::Handle.new+, and
112
+ # translate the +ArgumentError+ raised by Handle's invariants into
113
+ # a wire-layer +InvalidType+ via {Codec::Utils.wire_boundary}.
114
+ # The Value Object owns the id-range contract; this method only
115
+ # owns the frame shape.
116
+ def unpack_handle(payload)
117
+ bytes = payload.b
118
+ raise InvalidType, "ext 0x01 payload must be 4 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 4
119
+
120
+ id = bytes.unpack1("N") # : Integer
121
+ Codec::Utils.wire_boundary { RPC::Handle.new(id) }
122
+ end
123
+
124
+ # Encode the inner ext-0x02 map via {Encoder} (not +factory.dump+) so
125
+ # the embedded payload flows through the same boundary as a top-level
126
+ # encode — nested kobako values (Handle, nested Fault) reach the
127
+ # registered ext-type packers via the cached singleton.
128
+ def pack_fault(fault)
129
+ Encoder.encode("type" => fault.type, "message" => fault.message, "details" => fault.details)
130
+ end
131
+
132
+ # Peel the embedded msgpack map and hand it to +RPC::Fault.new+;
133
+ # translate the value-object's +ArgumentError+ into +InvalidType+
134
+ # at the wire boundary. Inner decode goes through {Decoder} (not
135
+ # +factory.load+) so the embedded +str+ payloads flow through the
136
+ # same UTF-8 validation as a top-level decode.
137
+ #
138
+ # This establishes a runtime cycle Factory → Decoder → Factory: the
139
+ # singleton instance feeds +Decoder.decode+, which re-enters this
140
+ # method when a nested ext 0x02 appears inside +details+. The recursion
141
+ # is bounded by msgpack nesting depth — identical to nested Array /
142
+ # Hash payloads — so no extra guard is needed. Do not switch back to
143
+ # +factory.load+ to "simplify": that path bypasses UTF-8 validation
144
+ # and re-opens the Decoder's special case for Fault (removed in M5).
145
+ def unpack_fault(payload)
146
+ map = Decoder.decode(payload)
147
+ raise InvalidType, "ext 0x02 payload must be a map" unless map.is_a?(Hash)
148
+
149
+ Codec::Utils.wire_boundary do
150
+ RPC::Fault.new(type: map["type"], message: map["message"], details: map["details"])
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Kobako
6
+ module Codec
7
+ # Wire-codec helpers shared by the host-side encoders and decoders.
8
+ # The single concern today is UTF-8 assertion at the wire boundary
9
+ # (SPEC.md → Wire Codec → str/bin Encoding Rules and Ext Types →
10
+ # ext 0x00). Two call sites lean on this:
11
+ #
12
+ # - {Decoder} validates +str+ family payloads as it walks the
13
+ # decoded value tree.
14
+ # - {Factory} validates the +ext 0x00+ Symbol payload after
15
+ # re-tagging the binary bytes as UTF-8.
16
+ #
17
+ # Encoding setup (re-tagging binary as UTF-8 when needed) stays at
18
+ # the caller — only the assertion shape is shared. The helper does
19
+ # not mutate +string+; it only inspects +String#valid_encoding?+
20
+ # against +string+'s current encoding tag.
21
+ module Utils
22
+ module_function
23
+
24
+ # Raise {InvalidEncoding} unless +string+'s bytes are valid under
25
+ # its current encoding tag. +label+ is the caller-supplied prefix
26
+ # for the error message (e.g. +"str payload"+, +"ext 0x00 payload"+).
27
+ def assert_utf8!(string, label)
28
+ return if string.valid_encoding?
29
+
30
+ raise InvalidEncoding, "#{label} is not valid UTF-8"
31
+ end
32
+
33
+ # Run +block+ at the wire boundary: every wire Value Object
34
+ # (Handle / Fault / Request / Response / Panic) raises
35
+ # +ArgumentError+ when an invariant is violated at construction,
36
+ # and the wire boundary surfaces those violations to callers as
37
+ # {InvalidType} so the public taxonomy stays
38
+ # {Kobako::Codec::Error} and never leaks +ArgumentError+ from the
39
+ # Ruby standard library.
40
+ #
41
+ # Wrap any block that constructs a wire Value Object from decoded
42
+ # bytes with this helper to keep the five decode sites uniform —
43
+ # Request / Response in +Kobako::RPC+, Panic map in
44
+ # +Kobako::Outcome+, and the Handle / Fault ext-type unpackers in
45
+ # {Factory}. Do not use it for general-purpose validation outside
46
+ # the wire boundary — host-layer +ArgumentError+ values should
47
+ # propagate unchanged.
48
+ def wire_boundary
49
+ yield
50
+ rescue ::ArgumentError => e
51
+ raise InvalidType, e.message
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "codec/error"
4
+
5
+ module Kobako
6
+ # Host-side MessagePack codec for the kobako wire contract — the
7
+ # byte-level layer (SPEC.md → Wire Codec). Two consumers sit on top:
8
+ # +Kobako::RPC+ pins the RPC framing (Request / Response)
9
+ # and +Kobako::Outcome+ owns the per-+#run+ outcome envelope (Result
10
+ # body / Panic map).
11
+ #
12
+ # Backed by the official +msgpack+ gem via {Factory}; {Encoder} and
13
+ # {Decoder} are thin wrappers that register the three kobako-specific
14
+ # ext types (0x00 Symbol, 0x01 Capability Handle, 0x02 Exception
15
+ # envelope) on a single +MessagePack::Factory+ instance. The Rust side
16
+ # mirrors this layer as the +codec+ module in the +kobako-wasm+ crate;
17
+ # the ext-code constants live as module-private values on {Factory}
18
+ # alongside +codec::EXT_SYMBOL+ / +codec::EXT_HANDLE+ /
19
+ # +codec::EXT_ERRENV+ on that side.
20
+ module Codec
21
+ end
22
+ end
23
+
24
+ require_relative "codec/utils"
25
+ require_relative "codec/factory"
26
+ require_relative "codec/encoder"
27
+ require_relative "codec/decoder"
data/lib/kobako/errors.rb CHANGED
@@ -34,8 +34,31 @@ module Kobako
34
34
  # (trap, OOM, unreachable) or when the wire layer detected a structural
35
35
  # violation that signals a corrupted guest execution environment
36
36
  # (zero-length OUTCOME_BUFFER, unknown outcome tag — SPEC E-02 / E-03).
37
+ #
38
+ # Two named subclasses cover the configured per-run caps from B-01:
39
+ #
40
+ # * {TimeoutError} — wall-clock +timeout+ exceeded (E-19).
41
+ # * {MemoryLimitError} — guest +memory.grow+ would exceed
42
+ # +memory_limit+ (E-20).
43
+ #
44
+ # Host Apps that only care about "guest is unrecoverable, discard the
45
+ # Sandbox" can rescue +TrapError+ and ignore the subclass; Host Apps that
46
+ # want to surface a specific reason to operators can rescue the subclass
47
+ # first.
37
48
  class TrapError < Error; end
38
49
 
50
+ # Wall-clock timeout cap exhausted. {SPEC.md E-19}[link:../../SPEC.md]:
51
+ # the absolute deadline +entry_time + timeout+ passed and the next guest
52
+ # wasm safepoint trapped. The Sandbox is unrecoverable after this point;
53
+ # discard and recreate before another execution.
54
+ class TimeoutError < TrapError; end
55
+
56
+ # Linear-memory cap exhausted. {SPEC.md E-20}[link:../../SPEC.md]:
57
+ # a guest +memory.grow+ would have pushed linear memory past the
58
+ # configured +memory_limit+. The Sandbox is unrecoverable after this
59
+ # point; discard and recreate before another execution.
60
+ class MemoryLimitError < TrapError; end
61
+
39
62
  # Sandbox / wire layer. Raised when the guest ran to completion but
40
63
  # execution failed due to a mruby script error, a protocol fault, or a
41
64
  # host-side wire decode failure on an otherwise valid outcome tag.
@@ -74,7 +97,7 @@ module Kobako
74
97
 
75
98
  # HandleTable lookup-failure error (unknown id passed to #fetch /
76
99
  # #release). A SandboxError subclass: per the wire-layer rule, an
77
- # unknown Handle id surfaces as a `type="undefined"` Response.err
100
+ # unknown Handle id surfaces as a `type="undefined"` Response.error
78
101
  # envelope inside RpcDispatcher and never reaches the Host App
79
102
  # directly; outside that path (e.g. tests poking the HandleTable
80
103
  # directly), it surfaces as a SandboxError.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Outcome
5
+ # SPEC.md → Outcome Envelope → Panic envelope ({SPEC.md Outcome
6
+ # Envelope}[link:../../../SPEC.md]). Wire-shaped failure record
7
+ # carried in the OUTCOME_BUFFER when the guest run terminates with
8
+ # an uncaught top-level exception.
9
+ #
10
+ # This is the **wire data**, not a raisable Ruby exception. The
11
+ # mapping from Panic to a three-layer Ruby exception (TrapError /
12
+ # SandboxError / ServiceError) happens at +Kobako::Outcome.decode+
13
+ # via +build_panic_error+ — callers never raise +Panic+ directly.
14
+ #
15
+ # The five fields mirror SPEC: +origin+ ("sandbox" / "service"),
16
+ # +klass+ (the guest-side exception class name as a String),
17
+ # +message+, +backtrace+ (Array of String), +details+ (any
18
+ # wire-legal value, nil when absent). Required-field validation is
19
+ # enforced at construction; the +ORIGIN_SANDBOX+ / +ORIGIN_SERVICE+
20
+ # constants pin the two SPEC-defined origin values.
21
+ #
22
+ # Built on the +class X < Data.define(...)+ subclass form so the
23
+ # class body is fully Steep-visible; ruby/rbs upstream documents
24
+ # this as the Steep-friendly shape and the +Style/DataInheritance+
25
+ # cop is disabled on that basis (see +.rubocop.yml+).
26
+ class Panic < Data.define(:origin, :klass, :message, :backtrace, :details)
27
+ ORIGIN_SANDBOX = "sandbox"
28
+ ORIGIN_SERVICE = "service"
29
+
30
+ def initialize(origin:, klass:, message:, backtrace: [], details: nil)
31
+ raise ArgumentError, "Panic origin must be String" unless origin.is_a?(String)
32
+ raise ArgumentError, "Panic class must be String" unless klass.is_a?(String)
33
+ raise ArgumentError, "Panic message must be String" unless message.is_a?(String)
34
+ unless backtrace.is_a?(Array) && backtrace.all?(String)
35
+ raise ArgumentError, "Panic backtrace must be Array of String"
36
+ end
37
+
38
+ super
39
+ end
40
+ end
41
+ end
42
+ end