kobako 0.12.0 → 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 +19 -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 +10 -7
  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 +10 -12
  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 -3
  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
@@ -8,8 +8,7 @@ module Kobako
8
8
  # the host↔guest boundary. {deep_wrap} allocates a +Kobako::Handle+ for
9
9
  # each non-wire-representable leaf on the host→guest +#run+ argument
10
10
  # path; {deep_restore} resolves each wire-decoded Handle back to its
11
- # host object on every guest→host value path the +#eval+ / +#run+
12
- # result and the yield-block result alike. {representable?} is the
11
+ # host object on every guest→host value path. {representable?} is the
13
12
  # by-value codec-type predicate that decides which leaves {deep_wrap}
14
13
  # must wrap: the closed 12-entry wire type set
15
14
  # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type
@@ -82,11 +81,11 @@ module Kobako
82
81
  # +Kobako::Handle+ leaf with the host-side object +handler+ resolves
83
82
  # it to. The symmetric inverse of {deep_wrap}: that walk allocates objects
84
83
  # into Handles on the host→guest argument path; this walk resolves
85
- # Handles back to their objects on every guest→host value path — the
86
- # +#eval+ / +#run+ result and the yield-block result alike. The walk
87
- # descends through Array elements and Hash keys and values one
88
- # structural level at a time; any non-Handle leaf passes through
89
- # unchanged.
84
+ # Handles back to their objects wherever a guest→host payload carries
85
+ # one an invocation result, a yield-block result, or a dispatch
86
+ # argument. The walk descends through Array elements and Hash keys and
87
+ # values one structural level at a time; any non-Handle leaf passes
88
+ # through unchanged.
90
89
  #
91
90
  # +value+ is a decoded Ruby value (a Handle here is a wire-decoded
92
91
  # +Kobako::Handle+, never a guest-forged one); +handler+ must
@@ -98,6 +97,10 @@ module Kobako
98
97
  case value
99
98
  when ::Array then value.map { |element| HandleWalk.deep_restore(element, handler) }
100
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.
101
104
  value.to_h { |key, val| [HandleWalk.deep_restore(key, handler), HandleWalk.deep_restore(val, handler)] }
102
105
  when Kobako::Handle then handler.fetch(value.id)
103
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.
@@ -170,12 +170,14 @@ module Kobako
170
170
  raise UndefinedTargetError, "method #{name.inspect} is not exposed to the guest"
171
171
  end
172
172
 
173
- # A Kobako::Handle arriving as a positional or keyword
174
- # argument identifies a host-side object previously allocated by a prior
175
- # transport call's Handle wrap. Resolve it back to the Ruby object before
176
- # the dispatch reaches +public_send+.
173
+ # Resolve every Kobako::Handle in an argument bare or nested in an
174
+ # Array / Hash — back to its host object before the dispatch reaches
175
+ # +public_send+, symmetric with the guest→host return path. A Handle id
176
+ # with no live entry surfaces as an unrecognized target.
177
177
  def resolve_arg(value, handler)
178
- value.is_a?(Kobako::Handle) ? require_live_object!(value.id, handler) : value
178
+ Kobako::Codec::HandleWalk.deep_restore(value, handler)
179
+ rescue Kobako::SandboxError => e
180
+ raise UndefinedTargetError, e.message
179
181
  end
180
182
 
181
183
  # Resolve a Request target to the Ruby object the registry (or
@@ -190,7 +192,7 @@ module Kobako
190
192
  when String
191
193
  resolve_path(target, namespaces)
192
194
  when Kobako::Handle
193
- resolve_handle(target, handler)
195
+ require_live_object!(target.id, handler)
194
196
  end
195
197
  end
196
198
 
@@ -200,10 +202,6 @@ module Kobako
200
202
  raise UndefinedTargetError, e.message
201
203
  end
202
204
 
203
- def resolve_handle(handle, handler)
204
- require_live_object!(handle.id, handler)
205
- end
206
-
207
205
  # Resolve +id+ through the Catalog::Handles. An unknown id
208
206
  # surfaces as UndefinedTargetError.
209
207
  def require_live_object!(id, handler)
@@ -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