kobako 0.12.1 → 0.12.2

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +11 -0
  4. data/Cargo.lock +15 -2
  5. data/Cargo.toml +6 -2
  6. data/README.md +1 -1
  7. data/crates/kobako-runtime/CHANGELOG.md +8 -0
  8. data/crates/kobako-runtime/Cargo.toml +23 -0
  9. data/crates/kobako-runtime/README.md +34 -0
  10. data/crates/kobako-runtime/src/dispatch.rs +22 -0
  11. data/crates/kobako-runtime/src/error.rs +64 -0
  12. data/crates/kobako-runtime/src/lib.rs +16 -0
  13. data/crates/kobako-runtime/src/runtime.rs +50 -0
  14. data/crates/kobako-runtime/src/snapshot.rs +46 -0
  15. data/crates/kobako-runtime/src/yielder.rs +22 -0
  16. data/crates/kobako-wasmtime/CHANGELOG.md +8 -0
  17. data/crates/kobako-wasmtime/Cargo.toml +62 -0
  18. data/crates/kobako-wasmtime/README.md +32 -0
  19. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/ambient.rs +3 -3
  20. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/cache.rs +30 -41
  21. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/capture.rs +2 -2
  22. data/crates/kobako-wasmtime/src/config.rs +25 -0
  23. data/crates/kobako-wasmtime/src/dispatch.rs +110 -0
  24. data/crates/kobako-wasmtime/src/driver.rs +285 -0
  25. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/exports.rs +5 -6
  26. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/frames.rs +70 -82
  27. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/guest_mem.rs +38 -15
  28. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/instance_pre.rs +13 -21
  29. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/invocation.rs +54 -49
  30. data/crates/kobako-wasmtime/src/lib.rs +47 -0
  31. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/trap.rs +29 -35
  32. data/data/kobako.wasm +0 -0
  33. data/ext/kobako/Cargo.toml +9 -32
  34. data/ext/kobako/src/runtime/bridge.rs +150 -0
  35. data/ext/kobako/src/runtime/errors.rs +45 -13
  36. data/ext/kobako/src/runtime.rs +156 -406
  37. data/ext/kobako/src/snapshot.rs +27 -62
  38. data/lib/kobako/catalog/handles.rb +3 -3
  39. data/lib/kobako/catalog/namespaces.rb +4 -0
  40. data/lib/kobako/catalog/snippets.rb +4 -0
  41. data/lib/kobako/codec/encoder.rb +5 -1
  42. data/lib/kobako/codec/factory.rb +41 -13
  43. data/lib/kobako/codec/handle_walk.rb +4 -0
  44. data/lib/kobako/errors.rb +18 -16
  45. data/lib/kobako/sandbox.rb +20 -18
  46. data/lib/kobako/sandbox_options.rb +25 -9
  47. data/lib/kobako/snapshot.rb +7 -13
  48. data/lib/kobako/transport/dispatcher.rb +2 -2
  49. data/lib/kobako/transport/response.rb +14 -14
  50. data/lib/kobako/transport/run.rb +2 -6
  51. data/lib/kobako/transport/yield.rb +1 -1
  52. data/lib/kobako/transport/yielder.rb +2 -2
  53. data/lib/kobako/version.rb +1 -1
  54. data/release-please-config.json +48 -3
  55. data/sig/kobako/codec/factory.rbs +3 -0
  56. data/sig/kobako/errors.rbs +7 -14
  57. data/sig/kobako/runtime.rbs +8 -3
  58. data/sig/kobako/sandbox.rbs +2 -2
  59. data/sig/kobako/sandbox_options.rbs +4 -2
  60. data/sig/kobako/snapshot.rbs +0 -3
  61. data/sig/kobako/transport/dispatcher.rbs +1 -1
  62. data/sig/kobako/transport/run.rbs +2 -2
  63. data/sig/kobako/transport/yielder.rbs +2 -2
  64. data/sig/kobako/transport.rbs +8 -0
  65. metadata +27 -12
  66. data/ext/kobako/src/runtime/config.rs +0 -25
  67. data/ext/kobako/src/runtime/dispatch.rs +0 -211
@@ -1,64 +1,39 @@
1
- //! `Kobako::Snapshot` — per-invocation observable bundle.
1
+ //! `Kobako::Snapshot` — the Ruby-facing per-invocation observable bundle.
2
2
  //!
3
- //! Every successful `Kobako::Runtime#eval` / `#run` returns one of these.
4
- //! It carries every observable the host needs to surface after a guest
5
- //! invocation: the OUTCOME_BUFFER bytes (`return_bytes`), the captured
6
- //! stdout / stderr byte slices with their truncation flags, and
7
- //! the wall-clock + memory-peak figures from `Kobako::Usage`.
8
- //!
9
- //! Ruby callers see the seven raw readers registered below; the helper
10
- //! methods that pack them into `Kobako::Capture` / `Kobako::Usage`
11
- //! (`Kobako::Snapshot#stdout` / `#stderr` / `#usage`) live in
3
+ //! The success-path view of the engine-neutral snapshot: the outcome bytes
4
+ //! and the two captured output channels, exposed through five raw readers.
5
+ //! The helper methods that pack them into `Kobako::Capture`
6
+ //! (`Kobako::Snapshot#stdout` / `#stderr`) live in
12
7
  //! `lib/kobako/snapshot.rb`. The split keeps the ext side a pure value
13
- //! carrier and lets Ruby own the convenience surface.
14
-
15
- use std::cell::Cell;
16
- use std::time::Duration;
8
+ //! carrier and lets Ruby own the convenience surface. Usage is not on the
9
+ //! Snapshot — `Sandbox#usage` reads it from `Kobako::Runtime#usage`, which
10
+ //! survives the trap path where no `Kobako::Snapshot` is produced.
17
11
 
18
12
  use magnus::{method, prelude::*, Error as MagnusError, RModule, RString, Ruby};
19
13
 
14
+ use kobako_runtime::snapshot::Capture;
15
+
20
16
  /// Per-invocation snapshot value. Magnus wraps it so a single ext call
21
- /// from `Runtime::eval` / `Runtime::run` returns the whole bundle —
22
- /// the Sandbox layer can decompose it without round-tripping into ext
23
- /// again. All fields are private; the seven public methods registered
24
- /// in `init` read them out one by one. The wall-clock duration is
25
- /// held as a `Cell<Duration>` only because magnus' `#[magnus::wrap]`
26
- /// macro requires interior mutability — every field is set once at
27
- /// construction time and never mutated again.
17
+ /// from `Runtime::eval` / `Runtime::run` returns the whole bundle — the
18
+ /// Sandbox layer decomposes it without round-tripping into ext again. The
19
+ /// fields are set once at construction and never mutated; the five public
20
+ /// methods registered in `init` read them out one by one.
28
21
  #[magnus::wrap(class = "Kobako::Snapshot", free_immediately, size)]
29
22
  pub(crate) struct Snapshot {
30
23
  return_bytes: Vec<u8>,
31
- stdout_bytes: Vec<u8>,
32
- stdout_truncated: bool,
33
- stderr_bytes: Vec<u8>,
34
- stderr_truncated: bool,
35
- wall_time: Cell<Duration>,
36
- memory_peak: usize,
24
+ stdout: Capture,
25
+ stderr: Capture,
37
26
  }
38
27
 
39
28
  impl Snapshot {
40
- /// Construct a fresh Snapshot from the per-invocation data the
41
- /// Runtime has just collected. Called from
42
- /// `crate::runtime::Runtime::build_snapshot` once the
43
- /// guest export has returned, the OUTCOME_BUFFER has been drained,
44
- /// and the capture pipes have been clipped to their caps.
45
- pub(crate) fn new(
46
- return_bytes: Vec<u8>,
47
- stdout_bytes: Vec<u8>,
48
- stdout_truncated: bool,
49
- stderr_bytes: Vec<u8>,
50
- stderr_truncated: bool,
51
- wall_time: Duration,
52
- memory_peak: usize,
53
- ) -> Self {
29
+ /// Bundle the success outputs the Runtime collected once the guest
30
+ /// export returned with an outcome: the drained OUTCOME_BUFFER bytes
31
+ /// and the capture pipes clipped to their caps.
32
+ pub(crate) fn new(return_bytes: Vec<u8>, stdout: Capture, stderr: Capture) -> Self {
54
33
  Self {
55
34
  return_bytes,
56
- stdout_bytes,
57
- stdout_truncated,
58
- stderr_bytes,
59
- stderr_truncated,
60
- wall_time: Cell::new(wall_time),
61
- memory_peak,
35
+ stdout,
36
+ stderr,
62
37
  }
63
38
  }
64
39
 
@@ -69,32 +44,24 @@ impl Snapshot {
69
44
 
70
45
  fn stdout_bytes(&self) -> RString {
71
46
  let ruby = Ruby::get().expect("Ruby thread");
72
- ruby.str_from_slice(&self.stdout_bytes)
47
+ ruby.str_from_slice(&self.stdout.bytes)
73
48
  }
74
49
 
75
50
  fn stdout_truncated(&self) -> bool {
76
- self.stdout_truncated
51
+ self.stdout.truncated
77
52
  }
78
53
 
79
54
  fn stderr_bytes(&self) -> RString {
80
55
  let ruby = Ruby::get().expect("Ruby thread");
81
- ruby.str_from_slice(&self.stderr_bytes)
56
+ ruby.str_from_slice(&self.stderr.bytes)
82
57
  }
83
58
 
84
59
  fn stderr_truncated(&self) -> bool {
85
- self.stderr_truncated
86
- }
87
-
88
- fn wall_time(&self) -> f64 {
89
- self.wall_time.get().as_secs_f64()
90
- }
91
-
92
- fn memory_peak(&self) -> usize {
93
- self.memory_peak
60
+ self.stderr.truncated
94
61
  }
95
62
  }
96
63
 
97
- /// Register `Kobako::Snapshot` plus its seven raw readers under the
64
+ /// Register `Kobako::Snapshot` plus its five raw readers under the
98
65
  /// `Kobako` module. Called from `crate::init` after `Kobako::Runtime`
99
66
  /// is registered so the magnus wrap macro can resolve the class name.
100
67
  pub(crate) fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
@@ -104,7 +71,5 @@ pub(crate) fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
104
71
  snapshot.define_method("stdout_truncated", method!(Snapshot::stdout_truncated, 0))?;
105
72
  snapshot.define_method("stderr_bytes", method!(Snapshot::stderr_bytes, 0))?;
106
73
  snapshot.define_method("stderr_truncated", method!(Snapshot::stderr_truncated, 0))?;
107
- snapshot.define_method("wall_time", method!(Snapshot::wall_time, 0))?;
108
- snapshot.define_method("memory_peak", method!(Snapshot::memory_peak, 0))?;
109
74
  Ok(())
110
75
  }
@@ -38,7 +38,7 @@ module Kobako
38
38
  # for it. +object+ is any host-side Ruby object to bind. Returns a
39
39
  # freshly-allocated +Kobako::Handle+ whose +#id+ falls in
40
40
  # +[Kobako::Handle::MIN_ID, Kobako::Handle::MAX_ID]+. Raises
41
- # +Kobako::HandlerExhaustedError+ if the next ID would exceed the
41
+ # +Kobako::HandleExhaustedError+ if the next ID would exceed the
42
42
  # cap. The cap is anchored on +Kobako::Handle+ — the wire codec
43
43
  # and the allocator share the same invariant.
44
44
  #
@@ -96,12 +96,12 @@ module Kobako
96
96
  end
97
97
 
98
98
  # Guard {#alloc} against issuing an ID past the cap. Returns +nil+
99
- # on success; raises +Kobako::HandlerExhaustedError+ at exhaustion.
99
+ # on success; raises +Kobako::HandleExhaustedError+ at exhaustion.
100
100
  def ensure_capacity!
101
101
  cap = Kobako::Handle::MAX_ID
102
102
  return unless @next_id > cap
103
103
 
104
- raise HandlerExhaustedError,
104
+ raise HandleExhaustedError,
105
105
  "Out of handle allocations: too many host objects were referenced " \
106
106
  "in a single invocation (limit #{cap})"
107
107
  end
@@ -82,6 +82,10 @@ module Kobako
82
82
  # first invocation, so the preamble is exactly the bindings that
83
83
  # existed at that moment — a bind reaching a +Kobako::Namespace+
84
84
  # after the seal raises +ArgumentError+ and never alters Frame 1.
85
+ # The memo is gated on the seal rather than dropped per mutation (the
86
+ # +Catalog::Snippets#encode+ approach) because a +Member+ bind lands
87
+ # on a child +Kobako::Namespace+, invisible to this collection; only
88
+ # the seal guarantees nothing further can change.
85
89
  def encode
86
90
  return @encoded if @encoded
87
91
 
@@ -44,6 +44,10 @@ 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
48
+ # seal, this one can fill eagerly and invalidate in +#register+
49
+ # because every mutation funnels through that single method — there is
50
+ # no out-of-sight child object to change the result behind its back.
47
51
  def encode
48
52
  return @encoded if @encoded
49
53
 
@@ -26,7 +26,11 @@ module Kobako
26
26
  # mapping is a closed set, and anything outside it is rejected by
27
27
  # the msgpack gem itself (arbitrary objects raise +NoMethodError+
28
28
  # from missing +to_msgpack+, integers outside i64..u64 raise
29
- # +RangeError+).
29
+ # +RangeError+). The +NoMethodError+ catch is deliberately broad:
30
+ # MessagePack signals "no wire representation" only through that error,
31
+ # so there is no narrower discriminator — a packer-internal
32
+ # +NoMethodError+ is likewise reported as +UnsupportedType+ rather than
33
+ # propagating.
30
34
  def self.encode(value)
31
35
  Factory.dump(value)
32
36
  rescue ::RangeError, ::NoMethodError => e
@@ -48,6 +48,16 @@ module Kobako
48
48
  EXT_ERRENV = 0x02
49
49
  private_constant :EXT_SYMBOL, :EXT_HANDLE, :EXT_ERRENV
50
50
 
51
+ # An ext 0x02 (Fault) envelope nests through its +details+ field, and
52
+ # each level re-enters the codec with a fresh +MessagePack+ unpacker
53
+ # whose built-in stack guard resets — so ext-envelope depth is tracked
54
+ # here instead. The cap matches the wire's overall nesting bound and
55
+ # keeps a nested chain from exhausting the native stack: an over-deep
56
+ # chain fails as a clean wire error, never a stack-level trap.
57
+ MAX_EXT_DEPTH = 128
58
+ EXT_DEPTH_KEY = :__kobako_codec_ext_depth__
59
+ private_constant :MAX_EXT_DEPTH, :EXT_DEPTH_KEY
60
+
51
61
  # Instance-level pass-through onto the wrapped +MessagePack::Factory+.
52
62
  # Spelled +def_instance_delegators+ rather than +def_delegators+ because
53
63
  # the class also extends +SingleForwardable+ (see the +extend+ block
@@ -129,9 +139,13 @@ module Kobako
129
139
  # Encode the inner ext-0x02 map via {Encoder} (not +factory.dump+) so
130
140
  # the embedded payload flows through the same boundary as a top-level
131
141
  # encode — nested kobako values (Handle, nested Fault) reach the
132
- # registered ext-type packers via the cached singleton.
142
+ # registered ext-type packers via the cached singleton. A +details+
143
+ # chain nested past {MAX_EXT_DEPTH} has no wire representation and
144
+ # surfaces as +UnsupportedType+.
133
145
  def pack_fault(fault)
134
- Encoder.encode("type" => fault.type, "message" => fault.message, "details" => fault.details)
146
+ within_ext_frame(UnsupportedType) do
147
+ Encoder.encode("type" => fault.type, "message" => fault.message, "details" => fault.details)
148
+ end
135
149
  end
136
150
 
137
151
  # Peel the embedded msgpack map and hand it to +Kobako::Fault.new+
@@ -139,19 +153,33 @@ module Kobako
139
153
  # +ArgumentError+ invariants surface as +InvalidType+ through the
140
154
  # decoder boundary. Inner decode goes through {Decoder} (not
141
155
  # +factory.load+) so the embedded +str+ payloads flow through the
142
- # same UTF-8 validation as a top-level decode.
143
- #
144
- # This establishes a runtime cycle Factory Decoder Factory: the
145
- # singleton instance feeds +Decoder.decode+, which re-enters this
146
- # method when a nested ext 0x02 appears inside +details+. The recursion
147
- # is bounded by msgpack nesting depth — identical to nested Array /
148
- # Hash payloads — so no extra guard is needed. Do not switch back to
149
- # +factory.load+ to "simplify": that path bypasses UTF-8 validation.
156
+ # same UTF-8 validation as a top-level decode. A nested ext 0x02 in
157
+ # +details+ re-enters this method, so {#within_ext_frame} bounds the
158
+ # chain depth to keep it from exhausting the native stack.
150
159
  def unpack_fault(payload)
151
- Decoder.decode(payload) do |map|
152
- raise InvalidType, "Fault payload must be a map" unless map.is_a?(Hash)
160
+ within_ext_frame(InvalidType) do
161
+ Decoder.decode(payload) do |map|
162
+ raise InvalidType, "Fault payload must be a map" unless map.is_a?(Hash)
163
+
164
+ Kobako::Fault.new(type: map["type"], message: map["message"], details: map["details"])
165
+ end
166
+ end
167
+ end
153
168
 
154
- Kobako::Fault.new(type: map["type"], message: map["message"], details: map["details"])
169
+ # Track ext-envelope re-entry depth and refuse a chain past
170
+ # {MAX_EXT_DEPTH}, raising +over_limit+ so the failure lands in the
171
+ # caller's existing wire-error class. The counter is thread-scoped and
172
+ # balanced by the +ensure+, so a raise mid-chain still unwinds it to
173
+ # its entry value.
174
+ def within_ext_frame(over_limit)
175
+ depth = (Thread.current[EXT_DEPTH_KEY] || 0) + 1
176
+ raise over_limit, "ext envelope nesting exceeds #{MAX_EXT_DEPTH} levels" if depth > MAX_EXT_DEPTH
177
+
178
+ Thread.current[EXT_DEPTH_KEY] = depth
179
+ begin
180
+ yield
181
+ ensure
182
+ Thread.current[EXT_DEPTH_KEY] = depth - 1
155
183
  end
156
184
  end
157
185
  end
@@ -97,6 +97,10 @@ module Kobako
97
97
  case value
98
98
  when ::Array then value.map { |element| HandleWalk.deep_restore(element, handler) }
99
99
  when ::Hash
100
+ # Rebuilt with each key restored: two distinct Handle keys that
101
+ # resolve to equal host objects collapse to the later pair, as in
102
+ # any Ruby Hash. The guest authored this payload, so that collapse
103
+ # is its own concern, not a fidelity guarantee the host owes it.
100
104
  value.to_h { |key, val| [HandleWalk.deep_restore(key, handler), HandleWalk.deep_restore(val, handler)] }
101
105
  when Kobako::Handle then handler.fetch(value.id)
102
106
  else value
data/lib/kobako/errors.rb CHANGED
@@ -35,7 +35,7 @@ module Kobako
35
35
  #
36
36
  # * {ModuleNotBuiltError} < {SetupError} — Guest Binary artifact absent
37
37
  # at +wasm_path+.
38
- # * {HandlerExhaustedError} < {SandboxError} — Handle id cap hit.
38
+ # * {HandleExhaustedError} < {SandboxError} — Handle id cap hit.
39
39
 
40
40
  # Base for all kobako-raised errors so callers that want to ignore the
41
41
  # taxonomy can rescue a single class.
@@ -89,10 +89,13 @@ module Kobako
89
89
  # +ModuleNotBuiltError+ first.
90
90
  class ModuleNotBuiltError < SetupError; end
91
91
 
92
- # Sandbox / wire layer. Raised when the guest ran to completion but
93
- # execution failed due to a mruby script error, a protocol fault, or a
94
- # host-side wire decode failure on an otherwise valid outcome tag.
95
- class SandboxError < Error
92
+ # The structured attribution the two invocation-failure classes carry
93
+ # from a decoded guest exception its +origin+, original +klass+,
94
+ # +backtrace_lines+, and +details+ so a Host App can inspect a failure
95
+ # beyond its message. Mixed into both rather than promoted to a shared
96
+ # superclass because +SandboxError+ and +ServiceError+ sit in distinct
97
+ # branches of the invocation-outcome taxonomy under +Kobako::Error+.
98
+ module StructuredError
96
99
  attr_reader :origin, :klass, :backtrace_lines, :details
97
100
 
98
101
  def initialize(message, origin: nil, klass: nil, backtrace_lines: nil, details: nil)
@@ -104,26 +107,25 @@ module Kobako
104
107
  end
105
108
  end
106
109
 
110
+ # Sandbox / wire layer. Raised when the guest ran to completion but
111
+ # execution failed due to a mruby script error, a protocol fault, or a
112
+ # host-side wire decode failure on an otherwise valid outcome tag.
113
+ class SandboxError < Error
114
+ include StructuredError
115
+ end
116
+
107
117
  # Service layer. Raised when a Service capability call inside a mruby
108
118
  # script reported an application-level failure that the script did not
109
119
  # rescue.
110
120
  class ServiceError < Error
111
- attr_reader :origin, :klass, :backtrace_lines, :details
112
-
113
- def initialize(message, origin: nil, klass: nil, backtrace_lines: nil, details: nil)
114
- super(message)
115
- @origin = origin
116
- @klass = klass
117
- @backtrace_lines = backtrace_lines
118
- @details = details
119
- end
121
+ include StructuredError
120
122
  end
121
123
 
122
- # HandlerExhaustedError is the canonical SandboxError subclass for the
124
+ # HandleExhaustedError is the canonical SandboxError subclass for the
123
125
  # id-cap-hit path. Raised when the per-invocation Handle ID counter in
124
126
  # Catalog::Handles reaches +0x7fff_ffff+ (2³¹ − 1) and further
125
127
  # allocation would exceed the cap.
126
- class HandlerExhaustedError < SandboxError; end
128
+ class HandleExhaustedError < SandboxError; end
127
129
 
128
130
  # BytecodeError is the SandboxError subclass raised when a
129
131
  # `#preload(binary:)` snippet fails structural validation during the
@@ -89,7 +89,9 @@ module Kobako
89
89
  # normalisation. The constructed +SandboxOptions+ is exposed as
90
90
  # +#options+ and the four caps remain readable directly on Sandbox via
91
91
  # +Forwardable+ delegation.
92
- def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil,
92
+ def initialize(wasm_path: nil,
93
+ stdout_limit: SandboxOptions::DEFAULT_OUTPUT_LIMIT,
94
+ stderr_limit: SandboxOptions::DEFAULT_OUTPUT_LIMIT,
93
95
  timeout: SandboxOptions::DEFAULT_TIMEOUT_SECONDS,
94
96
  memory_limit: SandboxOptions::DEFAULT_MEMORY_LIMIT)
95
97
  @wasm_path = wasm_path || Kobako::Runtime.default_path
@@ -207,17 +209,17 @@ module Kobako
207
209
 
208
210
  private
209
211
 
210
- # Configure the +Runtime+'s host↔guest dispatch wiring. Builds a
211
- # lambda that re-enters the guest via
212
- # +Runtime#yield_to_active_invocation+ and a dispatch +Proc+ that routes
213
- # guest→host calls through the stateless +Transport::Dispatcher+,
214
- # capturing +@services+ / +@handler+ in the closure. Both are registered
215
- # on the +Runtime+ once at construction time so the wasm ext callback can
216
- # fire without further setup.
212
+ # Configure the +Runtime+'s host↔guest dispatch wiring. Registers a
213
+ # dispatch +Proc+ that routes guest→host calls through the stateless
214
+ # +Transport::Dispatcher+, capturing +@services+ / +@handler+ in the
215
+ # closure. The ext hands the +Proc+ a per-dispatch +guest_yielder+ — a
216
+ # +String String+ callable that re-enters the in-flight guest to run a
217
+ # yielded block — which the +Dispatcher+ forwards to the +Transport::Yielder+
218
+ # it builds for the call. Registered once at construction time so the
219
+ # wasm ext callback can fire without further setup.
217
220
  def install_dispatch_proc!
218
- yield_to_guest = ->(args_bytes) { @runtime.yield_to_active_invocation(args_bytes) }
219
- @runtime.on_dispatch = lambda do |request_bytes|
220
- Transport::Dispatcher.dispatch(request_bytes, @services, @handler, yield_to_guest)
221
+ @runtime.on_dispatch = lambda do |request_bytes, guest_yielder|
222
+ Transport::Dispatcher.dispatch(request_bytes, @services, @handler, guest_yielder)
221
223
  end
222
224
  end
223
225
 
@@ -226,8 +228,7 @@ module Kobako
226
228
  # state — capture buffers, truncation predicates, and the
227
229
  # +Catalog::Handles+ counter — before the guest runs. The
228
230
  # +Catalog::Handles+ itself is held as +@handler+ and never exposed
229
- # beyond this class: SPEC.md Terminology pins it as "Not exposed to the
230
- # Host App".
231
+ # beyond this class it is not part of the Host App's surface.
231
232
  def begin_invocation!
232
233
  @services.seal!
233
234
  @handler.reset!
@@ -239,9 +240,10 @@ module Kobako
239
240
  # the +invoke!+ +ensure+ block so the usage record is populated on
240
241
  # every outcome — value return, +Kobako::TrapError+ (including
241
242
  # +TimeoutError+ / +MemoryLimitError+), +Kobako::SandboxError+,
242
- # and +Kobako::ServiceError+. On the success path the same figures
243
- # already arrive via +Snapshot#usage+; on the trap path the Snapshot
244
- # never reaches Ruby so the ext readout here is the only source.
243
+ # and +Kobako::ServiceError+. +Runtime#usage+ is the single source for
244
+ # both paths: the figures are stashed in the ext on every outcome, so
245
+ # unlike the +Snapshot+ (built only on success) the readout here also
246
+ # covers the trap path.
245
247
  #
246
248
  # The ext returns a positional 2-tuple +[wall_time, memory_peak]+
247
249
  # whose order matches the +Kobako::Usage+ field order; the
@@ -272,8 +274,8 @@ module Kobako
272
274
  # TrapError message so the failing export is identifiable.
273
275
  #
274
276
  # The yielded block must return a +Kobako::Snapshot+ — i.e. the
275
- # value of +Runtime#eval+ / +#run+ (SPEC.md Internal Concepts
276
- # Snapshot). The success path unpacks +#stdout+ / +#stderr+ into
277
+ # value of +Runtime#eval+ / +#run+. The success path unpacks
278
+ # +#stdout+ / +#stderr+ into
277
279
  # +Capture+ and feeds +#return_bytes+ to +Outcome.decode+; usage is
278
280
  # populated by the +ensure+ readout ({#read_usage!}) on every outcome.
279
281
  # The rescue chain is the single trap-translation boundary —
@@ -6,11 +6,13 @@ module Kobako
6
6
  # Data.define(...)+ subclass form (the Steep-friendly shape — see
7
7
  # +lib/kobako/outcome/panic.rb+).
8
8
  #
9
- # The +initialize+ method does double duty: it applies DEFAULT fallback
10
- # for absent values and normalises (timeout to Float seconds,
11
- # memory_limit to positive Integer bytes) before delegating to Data's
12
- # +super+. Anything that survives +SandboxOptions.new+ is a wire-ready
13
- # cap bundle the +Kobako::Runtime+ constructor consumes as-is.
9
+ # The +initialize+ normalises every cap before delegating to Data's
10
+ # +super+: +timeout+ to Float seconds, +memory_limit+ / +stdout_limit+ /
11
+ # +stderr_limit+ to positive Integer bytes. Each cap is +nil+-disablable
12
+ # (an absent argument takes its DEFAULT; an explicit +nil+ leaves the
13
+ # bound off), so all four behave uniformly. Anything that survives
14
+ # +SandboxOptions.new+ is a wire-ready cap bundle the +Kobako::Runtime+
15
+ # constructor consumes as-is.
14
16
  class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit)
15
17
  # Default wall-clock timeout for a single invocation: 60 seconds.
16
18
  DEFAULT_TIMEOUT_SECONDS = 60.0
@@ -25,12 +27,12 @@ module Kobako
25
27
 
26
28
  def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
27
29
  memory_limit: DEFAULT_MEMORY_LIMIT,
28
- stdout_limit: nil,
29
- stderr_limit: nil)
30
+ stdout_limit: DEFAULT_OUTPUT_LIMIT,
31
+ stderr_limit: DEFAULT_OUTPUT_LIMIT)
30
32
  timeout = normalize_timeout(timeout)
31
33
  memory_limit = normalize_memory_limit(memory_limit)
32
- stdout_limit ||= DEFAULT_OUTPUT_LIMIT
33
- stderr_limit ||= DEFAULT_OUTPUT_LIMIT
34
+ stdout_limit = normalize_output_limit(stdout_limit, "stdout_limit")
35
+ stderr_limit = normalize_output_limit(stderr_limit, "stderr_limit")
34
36
  super
35
37
  end
36
38
 
@@ -62,5 +64,19 @@ module Kobako
62
64
 
63
65
  memory_limit
64
66
  end
67
+
68
+ # Coerce a per-channel output cap (+stdout_limit+ / +stderr_limit+)
69
+ # into the byte cap the ext expects, or +nil+ to leave the channel
70
+ # uncapped. Same shape as +normalize_memory_limit+: a positive Integer
71
+ # when set, Float / zero / negative rejected. +name+ tags the
72
+ # +ArgumentError+ with the offending keyword.
73
+ def normalize_output_limit(limit, name)
74
+ return nil if limit.nil?
75
+ unless limit.is_a?(Integer) && limit.positive?
76
+ raise ArgumentError, "#{name} must be a positive Integer or nil, got #{limit.inspect}"
77
+ end
78
+
79
+ limit
80
+ end
65
81
  end
66
82
  end
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "capture"
4
- require_relative "usage"
5
4
 
6
5
  module Kobako
7
6
  # Kobako::Snapshot — per-invocation observable bundle returned from
8
7
  # +Kobako::Runtime#eval+ and +#run+.
9
8
  #
10
- # The magnus class (see ext/kobako/src/snapshot.rs) carries seven raw
9
+ # The magnus class (see ext/kobako/src/snapshot.rs) carries five raw
11
10
  # readers: +return_bytes+, +stdout_bytes+, +stdout_truncated+,
12
- # +stderr_bytes+, +stderr_truncated+, +wall_time+, +memory_peak+. This
13
- # file reopens the class to add the Ruby-side helpers that pack those
14
- # raw fields into the user-facing value objects +Kobako::Capture+ and
15
- # +Kobako::Usage+ the same shape +Kobako::Sandbox+ exposes to the
16
- # Host App.
11
+ # +stderr_bytes+, +stderr_truncated+. This file reopens the class to add
12
+ # the Ruby-side helpers that pack those raw fields into the user-facing
13
+ # value object +Kobako::Capture+ the same shape +Kobako::Sandbox+
14
+ # exposes to the Host App. Usage is not on the Snapshot; +Sandbox#usage+
15
+ # reads it from +Kobako::Runtime#usage+, which also covers the trap path
16
+ # where no Snapshot is produced.
17
17
  class Snapshot
18
18
  # Wrap the stdout capture pair (+stdout_bytes+, +stdout_truncated+)
19
19
  # as a +Kobako::Capture+ value object. The byte content never carries
@@ -28,11 +28,5 @@ module Kobako
28
28
  def stderr
29
29
  Capture.new(bytes: stderr_bytes, truncated: stderr_truncated)
30
30
  end
31
-
32
- # Wrap the per-last-invocation usage pair (+wall_time+,
33
- # +memory_peak+) as a +Kobako::Usage+ value object.
34
- def usage
35
- Usage.new(wall_time: wall_time, memory_peak: memory_peak)
36
- end
37
31
  end
38
32
  end
@@ -72,8 +72,8 @@ module Kobako
72
72
  # closure so the Dispatcher stays stateless and the registry doesn't
73
73
  # need to publish accessors for the Sandbox-owned +Catalog::Handles+
74
74
  # or +Runtime+. +yield_to_guest+ is a +String → String+ callable
75
- # (typically +Runtime#yield_to_active_invocation+ bound as a lambda)
76
- # used only when the Request carries +block_given: true+. Always
75
+ # (the ext's per-dispatch +Kobako::Runtime::GuestYielder+) used only
76
+ # when the Request carries +block_given: true+. Always
77
77
  # returns a binary String — every failure path is reified as a
78
78
  # Response.error envelope so the guest sees a transport error rather
79
79
  # than a wasm trap.
@@ -38,20 +38,6 @@ module Kobako
38
38
  new(status: STATUS_ERROR, payload: fault)
39
39
  end
40
40
 
41
- # Decode +bytes+ into a {Response}. Raises +Codec::InvalidType+ when the
42
- # envelope is not the expected 2-element msgpack array, or when the
43
- # Value Object's construction invariants reject the decoded fields.
44
- def self.decode(bytes)
45
- Codec::Decoder.decode(bytes) do |arr|
46
- unless arr.is_a?(Array) && arr.length == 2
47
- raise Codec::InvalidType, "Response envelope is malformed (expected a 2-element array)"
48
- end
49
-
50
- status, payload = arr
51
- new(status: status, payload: payload)
52
- end
53
- end
54
-
55
41
  def initialize(status:, payload:)
56
42
  unless [STATUS_OK, STATUS_ERROR].include?(status)
57
43
  raise ArgumentError, "Response status must be 0 (ok) or 1 (error), got #{status.inspect}"
@@ -71,6 +57,20 @@ module Kobako
71
57
  def encode
72
58
  Codec::Encoder.encode([status, payload])
73
59
  end
60
+
61
+ # Decode +bytes+ into a {Response}. Raises +Codec::InvalidType+ when the
62
+ # envelope is not the expected 2-element msgpack array, or when the
63
+ # Value Object's construction invariants reject the decoded fields.
64
+ def self.decode(bytes)
65
+ Codec::Decoder.decode(bytes) do |arr|
66
+ unless arr.is_a?(Array) && arr.length == 2
67
+ raise Codec::InvalidType, "Response envelope is malformed (expected a 2-element array)"
68
+ end
69
+
70
+ status, payload = arr
71
+ new(status: status, payload: payload)
72
+ end
73
+ end
74
74
  end
75
75
  end
76
76
  end
@@ -43,8 +43,8 @@ module Kobako
43
43
 
44
44
  def initialize(entrypoint:, args: [], kwargs: {})
45
45
  entrypoint = normalize_entrypoint(entrypoint)
46
- args = validate_args!(args)
47
- kwargs = validate_kwargs!(kwargs)
46
+ validate_args!(args)
47
+ validate_kwargs!(kwargs)
48
48
  super
49
49
  end
50
50
 
@@ -100,8 +100,6 @@ module Kobako
100
100
  def validate_args!(args)
101
101
  raise ArgumentError, "arguments must be an Array" unless args.is_a?(Array)
102
102
  raise ArgumentError, forged_handle_message("arguments") if args.any?(Kobako::Handle)
103
-
104
- args
105
103
  end
106
104
 
107
105
  # Reject a non-Symbol kwargs key, and a +Kobako::Handle+ arriving
@@ -117,8 +115,6 @@ module Kobako
117
115
  "keyword argument keys must be Symbols (got #{bad_keys.inspect})"
118
116
  end
119
117
  raise ArgumentError, forged_handle_message("keyword argument values") if kwargs.each_value.any?(Kobako::Handle)
120
-
121
- kwargs
122
118
  end
123
119
 
124
120
  # Single source of truth for the forged-Handle reject message so the
@@ -67,7 +67,7 @@ module Kobako
67
67
  raise Codec::InvalidType, "YieldResponse must carry at least one byte" if bytes.empty?
68
68
 
69
69
  tag = bytes.getbyte(0) # : Integer
70
- body = bytes.byteslice(1, bytes.bytesize - 1) || +""
70
+ body = bytes.byteslice(1, bytes.bytesize - 1) # : String
71
71
 
72
72
  reject_dead_tag!(tag)
73
73
  new(tag: tag, value: Codec::Decoder.decode(body))
@@ -28,8 +28,8 @@ module Kobako
28
28
  # dispatch completes; any later call to a stashed Yielder then raises
29
29
  # +LocalJumpError+ — the observable shape of an escaped Yielder.
30
30
  class Yielder
31
- # +yield_to_guest+ is a +String → String+ callable (typically
32
- # +Runtime#yield_to_active_invocation+ bound through a lambda) that
31
+ # +yield_to_guest+ is a +String → String+ callable (the ext's
32
+ # per-dispatch +Kobako::Runtime::GuestYielder+) that
33
33
  # {#yield} invokes to re-enter the guest; +break_tag+ is the +catch+
34
34
  # throw tag the Dispatcher matches against to unwind the Service on
35
35
  # +tag 0x02+. +handler+ is the Sandbox's +Kobako::Catalog::Handles+,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- VERSION = "0.12.1"
4
+ VERSION = "0.12.2"
5
5
  end