kobako 0.2.1 → 0.4.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +205 -59
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +2 -2
  6. data/ext/kobako/src/wasm/cache.rs +15 -7
  7. data/ext/kobako/src/wasm/dispatch.rs +88 -36
  8. data/ext/kobako/src/wasm/host_state.rs +298 -55
  9. data/ext/kobako/src/wasm/instance.rs +477 -160
  10. data/ext/kobako/src/wasm.rs +20 -5
  11. data/lib/kobako/capture.rb +12 -10
  12. data/lib/kobako/codec/decoder.rb +3 -4
  13. data/lib/kobako/codec/encoder.rb +1 -1
  14. data/lib/kobako/codec/error.rb +3 -2
  15. data/lib/kobako/codec/factory.rb +24 -17
  16. data/lib/kobako/codec/utils.rb +105 -12
  17. data/lib/kobako/codec.rb +2 -1
  18. data/lib/kobako/errors.rb +22 -10
  19. data/lib/kobako/handle.rb +62 -0
  20. data/lib/kobako/handle_table.rb +119 -0
  21. data/lib/kobako/invocation.rb +143 -0
  22. data/lib/kobako/outcome/panic.rb +2 -2
  23. data/lib/kobako/outcome.rb +61 -24
  24. data/lib/kobako/rpc/dispatcher.rb +30 -28
  25. data/lib/kobako/rpc/envelope.rb +10 -10
  26. data/lib/kobako/rpc/fault.rb +4 -3
  27. data/lib/kobako/rpc/namespace.rb +3 -3
  28. data/lib/kobako/rpc/server.rb +23 -33
  29. data/lib/kobako/rpc/wire_error.rb +23 -0
  30. data/lib/kobako/sandbox.rb +211 -136
  31. data/lib/kobako/sandbox_options.rb +73 -0
  32. data/lib/kobako/snippet/binary.rb +30 -0
  33. data/lib/kobako/snippet/source.rb +28 -0
  34. data/lib/kobako/snippet/table.rb +174 -0
  35. data/lib/kobako/snippet.rb +20 -0
  36. data/lib/kobako/usage.rb +41 -0
  37. data/lib/kobako/version.rb +1 -1
  38. data/lib/kobako.rb +1 -0
  39. data/sig/kobako/codec/factory.rbs +1 -1
  40. data/sig/kobako/codec/utils.rbs +10 -0
  41. data/sig/kobako/errors.rbs +3 -0
  42. data/sig/kobako/handle.rbs +19 -0
  43. data/sig/kobako/handle_table.rbs +23 -0
  44. data/sig/kobako/invocation.rbs +25 -0
  45. data/sig/kobako/outcome.rbs +1 -1
  46. data/sig/kobako/rpc/dispatcher.rbs +7 -7
  47. data/sig/kobako/rpc/envelope.rbs +3 -3
  48. data/sig/kobako/rpc/server.rbs +1 -7
  49. data/sig/kobako/rpc/wire_error.rbs +6 -0
  50. data/sig/kobako/sandbox.rbs +22 -17
  51. data/sig/kobako/sandbox_options.rbs +32 -0
  52. data/sig/kobako/snippet/binary.rbs +12 -0
  53. data/sig/kobako/snippet/source.rbs +13 -0
  54. data/sig/kobako/snippet/table.rbs +36 -0
  55. data/sig/kobako/snippet.rbs +4 -0
  56. data/sig/kobako/usage.rbs +11 -0
  57. data/sig/kobako/wasm.rbs +5 -1
  58. metadata +21 -5
  59. data/lib/kobako/rpc/handle.rb +0 -38
  60. data/lib/kobako/rpc/handle_table.rb +0 -107
  61. data/sig/kobako/rpc/handle.rbs +0 -19
  62. data/sig/kobako/rpc/handle_table.rbs +0 -25
@@ -3,12 +3,12 @@
3
3
  module Kobako
4
4
  module RPC
5
5
  # A named grouping of Members for one Sandbox
6
- # ({SPEC.md B-07..B-11}[link:../../../SPEC.md]). Returned by
6
+ # ({docs/behavior.md B-07..B-11}[link:../../../docs/behavior.md]). Returned by
7
7
  # +Sandbox#define+. Each instance owns a flat name→object table of
8
8
  # Members; member binding is validated against {NAME_PATTERN}.
9
9
  class Namespace
10
10
  # Ruby constant-name pattern shared by Namespace and Member names
11
- # ({SPEC.md B-07/B-08 Notes}[link:../../../SPEC.md]).
11
+ # ({docs/behavior.md B-07/B-08 Notes}[link:../../../docs/behavior.md]).
12
12
  NAME_PATTERN = /\A[A-Z]\w*\z/
13
13
 
14
14
  attr_reader :name, :members
@@ -26,7 +26,7 @@ module Kobako
26
26
  # object that responds to the methods guest code will invoke. Returns
27
27
  # +self+ for chaining. Raises +ArgumentError+ when +member+ does not
28
28
  # match the constant pattern, or a Member of the same name is already
29
- # bound ({SPEC.md B-11}[link:../../../SPEC.md]).
29
+ # bound ({docs/behavior.md B-11}[link:../../../docs/behavior.md]).
30
30
  def bind(member, object)
31
31
  member_str = validate_member_name!(member)
32
32
  raise ArgumentError, "Member #{@name}::#{member_str} is already bound" if @members.key?(member_str)
@@ -4,7 +4,7 @@ require "msgpack"
4
4
  require_relative "../errors"
5
5
  require_relative "envelope"
6
6
  require_relative "namespace"
7
- require_relative "handle_table"
7
+ require_relative "../handle_table"
8
8
  require_relative "dispatcher"
9
9
 
10
10
  module Kobako
@@ -12,7 +12,7 @@ module Kobako
12
12
  # Kobako::RPC::Server — per-Sandbox host-side RPC coordinator. Maintains
13
13
  # the Namespace / Member registry, owns the HandleTable, and routes
14
14
  # incoming Requests to the resolved Service object
15
- # ({SPEC.md B-07..B-21}[link:../../../SPEC.md]).
15
+ # ({docs/behavior.md B-07..B-21}[link:../../../docs/behavior.md]).
16
16
  #
17
17
  # Public API:
18
18
  #
@@ -24,10 +24,11 @@ module Kobako
24
24
  #
25
25
  # Namespaces live at +Kobako::RPC::Namespace+
26
26
  # (lib/kobako/rpc/namespace.rb). The opaque Handle allocator lives at
27
- # +Kobako::RPC::HandleTable+
28
- # (lib/kobako/rpc/handle_table.rb). Dispatch helpers live at
29
- # +Kobako::RPC::Dispatcher+
30
- # (lib/kobako/rpc/dispatcher.rb).
27
+ # +Kobako::HandleTable+ (lib/kobako/handle_table.rb) and is owned by
28
+ # the Sandbox the Server only holds an injected reference so RPC
29
+ # dispatch resolves against the same table the wire layer allocates
30
+ # into (docs/behavior.md B-19). Dispatch helpers live at
31
+ # +Kobako::RPC::Dispatcher+ (lib/kobako/rpc/dispatcher.rb).
31
32
  class Server
32
33
  # Build a fresh Server. +handle_table+ is an internal seam that
33
34
  # injects a pre-configured +HandleTable+; tests pass one whose +next_id+
@@ -39,14 +40,14 @@ module Kobako
39
40
  @sealed = false
40
41
  end
41
42
 
42
- # Declare or retrieve the Namespace named +name+ (idempotent — SPEC.md B-10).
43
+ # Declare or retrieve the Namespace named +name+ (idempotent — docs/behavior.md B-10).
43
44
  # +name+ is a constant-form name as a +Symbol+ or +String+ (must satisfy
44
45
  # +Namespace::NAME_PATTERN+). Returns the +Kobako::RPC::Namespace+ for
45
46
  # that name, creating it if it does not exist. Raises +ArgumentError+
46
47
  # when +name+ is malformed, or when called after the owning Sandbox has
47
- # been sealed by +#run+.
48
+ # been sealed by its first invocation ({docs/behavior.md B-07}[link:../../../docs/behavior.md]).
48
49
  def define(name)
49
- raise ArgumentError, "cannot define after Sandbox#run has been invoked" if @sealed
50
+ raise ArgumentError, "cannot define after first Sandbox invocation" if @sealed
50
51
 
51
52
  name_str = name.to_s
52
53
  unless Namespace::NAME_PATTERN.match?(name_str)
@@ -76,11 +77,6 @@ module Kobako
76
77
  !namespace.nil? && !member_name.nil? && !namespace[member_name].nil?
77
78
  end
78
79
 
79
- # Returns all declared +Kobako::RPC::Namespace+ instances as an +Array+.
80
- def namespaces
81
- @namespaces.values
82
- end
83
-
84
80
  # Returns the number of declared namespaces as an +Integer+.
85
81
  def size
86
82
  @namespaces.size
@@ -91,8 +87,9 @@ module Kobako
91
87
  @namespaces.empty?
92
88
  end
93
89
 
94
- # Structured Frame 1 description. Called by +Sandbox#run+ when assembling
95
- # stdin Frame 1 ({SPEC.md B-02}[link:../../../SPEC.md]). Returns an
90
+ # Structured Frame 1 description. Called by +Sandbox#eval+ when
91
+ # assembling stdin Frame 1
92
+ # ({docs/behavior.md B-02}[link:../../../docs/behavior.md]). Returns an
96
93
  # unencoded preamble array — an +Array+ of two-element +[name, members]+
97
94
  # arrays, one per declared namespace.
98
95
  def to_preamble
@@ -100,7 +97,7 @@ module Kobako
100
97
  end
101
98
 
102
99
  # Encode the preamble as msgpack bytes for stdin Frame 1 delivery
103
- # ({SPEC.md B-02}[link:../../../SPEC.md]). Uses plain MessagePack (no
100
+ # ({docs/behavior.md B-02}[link:../../../docs/behavior.md]). Uses plain MessagePack (no
104
101
  # kobako ext types) because the preamble contains only strings — no
105
102
  # Handles or Fault envelopes. Structure:
106
103
  # +[["Namespace", ["MemberA", "MemberB"]], ...]+. Returns a binary
@@ -109,8 +106,8 @@ module Kobako
109
106
  MessagePack.pack(to_preamble)
110
107
  end
111
108
 
112
- # Mark the Server as sealed. Called by +Sandbox#run+ on first run.
113
- # After sealing, #define raises ArgumentError. Idempotent.
109
+ # Mark the Server as sealed. Called by +Sandbox+ on the first
110
+ # invocation. After sealing, #define raises ArgumentError. Idempotent.
114
111
  def seal!
115
112
  @sealed = true
116
113
  self
@@ -121,26 +118,19 @@ module Kobako
121
118
  @sealed
122
119
  end
123
120
 
124
- # Reset the HandleTable for a new +#run+ boundary. Called by +Sandbox#run+
125
- # before each invocation ({SPEC.md B-19}[link:../../../SPEC.md]).
126
- def reset_handles!
127
- @handle_table.reset!
128
- end
129
-
130
121
  # Dispatch a single RPC request and return the encoded response bytes
131
- # ({SPEC.md B-12}[link:../../../SPEC.md]). +request_bytes+ is a
122
+ # ({docs/behavior.md B-12}[link:../../../docs/behavior.md]). +request_bytes+ is a
132
123
  # msgpack-encoded Request envelope. Called by the Rust ext from inside
133
124
  # +__kobako_dispatch+. Always returns a binary +String+ — never raises.
134
- # Delegates to +Dispatcher.dispatch+ which reifies any failure as a
135
- # +Response.error+ envelope so the guest sees the failure as a normal RPC
136
- # error rather than a wasm trap.
125
+ # Forwards both the Server (for namespace lookup) and the injected
126
+ # +HandleTable+ (for Handle resolution / return-value wrapping) to
127
+ # +Dispatcher.dispatch+. The Server holds the HandleTable as an
128
+ # injected reference, not an owned resource — the Sandbox owns it
129
+ # (B-19) — so the table is not exposed via accessors.
137
130
  def dispatch(request_bytes)
138
- Dispatcher.dispatch(request_bytes, self)
131
+ Dispatcher.dispatch(request_bytes, self, @handle_table)
139
132
  end
140
133
 
141
- # Expose the +HandleTable+ for tests and wire-layer Handle wrapping.
142
- attr_reader :handle_table
143
-
144
134
  private
145
135
 
146
136
  # Split +target+ on the +::+ separator and resolve the namespace half.
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Kobako
6
+ module RPC
7
+ # +Kobako::SandboxError+ subclass raised when the host detects a
8
+ # structural violation of the wire contract while decoding bytes
9
+ # produced by the guest (a malformed Outcome envelope, a result body
10
+ # that fails msgpack decode, a Panic envelope missing required
11
+ # fields). Distinct from a Wasm trap (engine signalled the guest
12
+ # runtime is unrecoverable) and from a normal sandbox-layer failure
13
+ # (the script raised but the protocol was respected): a +WireError+
14
+ # always indicates the guest runtime is corrupted — the only safe
15
+ # recovery is to discard the Sandbox and start a new invocation.
16
+ #
17
+ # Inherits from +Kobako::SandboxError+ so a single
18
+ # +rescue Kobako::SandboxError+ still catches it; callers that want
19
+ # to distinguish wire-violation paths from script failures can
20
+ # +rescue Kobako::RPC::WireError+ directly.
21
+ class WireError < Kobako::SandboxError; end
22
+ end
23
+ end
@@ -1,24 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "forwardable"
4
+
3
5
  require_relative "capture"
4
6
  require_relative "errors"
7
+ require_relative "handle_table"
8
+ require_relative "invocation"
5
9
  require_relative "outcome"
6
10
  require_relative "rpc/server"
7
11
  require_relative "rpc/envelope"
12
+ require_relative "sandbox_options"
13
+ require_relative "snippet"
14
+ require_relative "usage"
8
15
 
9
16
  module Kobako
10
17
  # Kobako::Sandbox — the user-facing entry point for executing guest mruby
11
18
  # scripts inside a wasmtime-hosted Wasm module
12
- # ({SPEC.md B-01}[link:../../SPEC.md]).
19
+ # ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
13
20
  #
14
- # The Sandbox owns the +Kobako::Wasm::Instance+, the per-instance RPC Server
15
- # (which itself owns the per-run HandleTable), and the per-channel byte
16
- # caches for guest stdout / stderr capture. The underlying wasmtime Engine
17
- # and compiled Module are cached at process scope by the native ext and
18
- # never surface to Ruby constructing many Sandboxes amortises both costs
19
- # automatically.
21
+ # The Sandbox owns the +Kobako::Wasm::Instance+, the per-Sandbox
22
+ # +Kobako::HandleTable+ ({docs/behavior.md B-19}[link:../../docs/behavior.md]),
23
+ # the per-instance RPC Server (which receives the HandleTable by
24
+ # injection so guest→host dispatch and host→guest auto-wrap share one
25
+ # allocator), and the per-channel byte caches for guest stdout / stderr
26
+ # capture. The underlying wasmtime Engine and compiled Module are cached
27
+ # at process scope by the native ext and never surface to Ruby —
28
+ # constructing many Sandboxes amortises both costs automatically.
20
29
  #
21
- # Output capture policy ({SPEC.md B-04}[link:../../SPEC.md]): the
30
+ # Output capture policy ({docs/behavior.md B-04}[link:../../docs/behavior.md]): the
22
31
  # per-channel cap (+stdout_limit+ / +stderr_limit+) is enforced inside the
23
32
  # WASI pipe — the host buffer stops growing at the cap, subsequent guest
24
33
  # writes on that channel fail or are dropped, and +#run+ still returns
@@ -27,164 +36,211 @@ module Kobako
27
36
  # +#stdout_truncated?+ / +#stderr_truncated?+ are the only way to observe
28
37
  # that the cap was hit.
29
38
  class Sandbox
30
- # Default per-channel capture ceiling: 1 MiB
31
- # ({SPEC.md B-01}[link:../../SPEC.md]).
32
- DEFAULT_OUTPUT_LIMIT = 1 << 20
33
-
34
- # Default wall-clock timeout for a single +#run+: 60 seconds
35
- # ({SPEC.md B-01}[link:../../SPEC.md]).
36
- DEFAULT_TIMEOUT_SECONDS = 60.0
37
-
38
- # Default cap on guest linear memory growth: 5 MiB
39
- # ({SPEC.md B-01}[link:../../SPEC.md]).
40
- DEFAULT_MEMORY_LIMIT = 5 << 20
39
+ extend Forwardable
41
40
 
42
41
  attr_reader :wasm_path, :instance,
43
- :stdout_limit, :stderr_limit,
44
- :timeout, :memory_limit, :services
42
+ :options,
43
+ :services, :snippets
44
+
45
+ # Per-cap accessors forward to the immutable +SandboxOptions+ Value
46
+ # Object so the Host App still reads them off Sandbox directly.
47
+ def_delegators :@options, :timeout, :memory_limit, :stdout_limit, :stderr_limit
45
48
 
46
49
  # Returns the bytes the guest wrote to stdout during the most recent
47
- # +#run+ as a UTF-8 String, clipped at +stdout_limit+. Empty before any
48
- # +#run+ call. {SPEC.md B-04}[link:../../SPEC.md] — the byte content
49
- # never contains a truncation sentinel; use +#stdout_truncated?+ to
50
+ # invocation as a UTF-8 String, clipped at +stdout_limit+. Empty before
51
+ # any invocation. {docs/behavior.md B-04}[link:../../docs/behavior.md] — the byte
52
+ # content never contains a truncation sentinel; use +#stdout_truncated?+ to
50
53
  # observe overflow.
51
54
  def stdout
52
55
  @stdout_capture.bytes
53
56
  end
54
57
 
55
58
  # Returns the bytes the guest wrote to stderr during the most recent
56
- # +#run+ as a UTF-8 String, clipped at +stderr_limit+. Empty before any
57
- # +#run+ call. Mirror of +#stdout+.
59
+ # invocation as a UTF-8 String, clipped at +stderr_limit+. Empty before
60
+ # any invocation. Mirror of +#stdout+.
58
61
  def stderr
59
62
  @stderr_capture.bytes
60
63
  end
61
64
 
62
- # Returns +true+ iff stdout capture during the most recent +#run+
63
- # exceeded +stdout_limit+ ({SPEC.md B-04}[link:../../SPEC.md]). Resets
64
- # to +false+ at the start of the next +#run+ ({SPEC.md
65
- # B-03}[link:../../SPEC.md]).
65
+ # Returns +true+ iff stdout capture during the most recent invocation
66
+ # exceeded +stdout_limit+ ({docs/behavior.md B-04}[link:../../docs/behavior.md]). Resets
67
+ # to +false+ at the start of the next invocation
68
+ # ({docs/behavior.md B-03}[link:../../docs/behavior.md]).
66
69
  def stdout_truncated?
67
70
  @stdout_capture.truncated?
68
71
  end
69
72
 
70
- # Returns +true+ iff stderr capture during the most recent +#run+
73
+ # Returns +true+ iff stderr capture during the most recent invocation
71
74
  # exceeded +stderr_limit+. Mirror of +#stdout_truncated?+.
72
75
  def stderr_truncated?
73
76
  @stderr_capture.truncated?
74
77
  end
75
78
 
79
+ # Returns the +Kobako::Usage+ value object for the most recent
80
+ # invocation ({docs/behavior.md B-35}[link:../../docs/behavior.md]).
81
+ # Carries +wall_time+ (Float seconds the guest export call spent
82
+ # inside wasmtime) and +memory_peak+ (Integer bytes, high-water of
83
+ # the per-invocation +memory.grow+ delta past the entry-time
84
+ # baseline). Returns +Kobako::Usage::EMPTY+ before any invocation;
85
+ # populated on every outcome — including +TrapError+ — so the Host
86
+ # App can read it after rescuing a trap to diagnose budget
87
+ # consumption.
88
+ attr_reader :usage
89
+
76
90
  # Build a fresh Sandbox.
77
91
  #
78
92
  # +wasm_path+ is the absolute path to the Guest Binary; defaults to the
79
- # gem-bundled +data/kobako.wasm+. +stdout_limit+ and +stderr_limit+ cap
80
- # the per-run byte ceiling for each capture channel (default 1 MiB;
81
- # +nil+ disables the cap). +timeout+ is the wall-clock cap on a single
82
- # +#run+ in seconds ({SPEC.md B-01}[link:../../SPEC.md]; default 60.0,
83
- # +nil+ disables); +memory_limit+ caps guest linear memory growth in
84
- # bytes ({SPEC.md B-01, E-20}[link:../../SPEC.md]; default 5 MiB,
85
- # +nil+ disables).
93
+ # gem-bundled +data/kobako.wasm+. The four caps (+stdout_limit+,
94
+ # +stderr_limit+, +timeout+, +memory_limit+) are forwarded verbatim to
95
+ # +Kobako::SandboxOptions+, which owns their DEFAULT fallback and
96
+ # normalisation. The constructed +SandboxOptions+ is exposed as
97
+ # +#options+ and the four caps remain readable directly on Sandbox via
98
+ # +Forwardable+ delegation.
86
99
  def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil,
87
- timeout: DEFAULT_TIMEOUT_SECONDS,
88
- memory_limit: DEFAULT_MEMORY_LIMIT)
100
+ timeout: SandboxOptions::DEFAULT_TIMEOUT_SECONDS,
101
+ memory_limit: SandboxOptions::DEFAULT_MEMORY_LIMIT)
89
102
  @wasm_path = wasm_path || Kobako::Wasm.default_path
90
- @stdout_limit = stdout_limit || DEFAULT_OUTPUT_LIMIT
91
- @stderr_limit = stderr_limit || DEFAULT_OUTPUT_LIMIT
92
- @timeout = normalize_timeout(timeout)
93
- @memory_limit = normalize_memory_limit(memory_limit)
94
- @services = Kobako::RPC::Server.new
95
- @instance = Kobako::Wasm::Instance.from_path(@wasm_path, @timeout, @memory_limit, @stdout_limit, @stderr_limit)
103
+ @options = SandboxOptions.new(timeout: timeout, memory_limit: memory_limit, stdout_limit: stdout_limit,
104
+ stderr_limit: stderr_limit)
105
+ @handle_table = HandleTable.new
106
+ @services = Kobako::RPC::Server.new(handle_table: @handle_table)
107
+ @snippets = Snippet::Table.new
108
+ @instance = Kobako::Wasm::Instance.from_path(@wasm_path, @options.timeout, @options.memory_limit,
109
+ @options.stdout_limit, @options.stderr_limit)
96
110
  @instance.server = @services
97
- clear_captures!
111
+ reset_invocation_state!
98
112
  end
99
113
 
100
114
  # Declare or retrieve the Namespace named +name+ on this Sandbox
101
- # ({SPEC.md B-07, B-09, B-10}[link:../../SPEC.md]). +name+ must be a
115
+ # ({docs/behavior.md B-07, B-09, B-10}[link:../../docs/behavior.md]). +name+ must be a
102
116
  # Symbol or String in constant form. Returns the +Kobako::RPC::Namespace+.
103
117
  #
104
- # Raises +ArgumentError+ when called after +#run+, or when +name+ does
105
- # not match the constant-name pattern.
118
+ # Raises +ArgumentError+ when called after the first invocation, or
119
+ # when +name+ does not match the constant-name pattern.
106
120
  def define(name)
107
121
  @services.define(name)
108
122
  end
109
123
 
110
- # Execute a guest mruby script
111
- # ({SPEC.md B-02 / B-03}[link:../../SPEC.md]). +source+ is the mruby
112
- # source code as a UTF-8 String. Returns the deserialized last
113
- # expression of the script.
124
+ # Register a snippet on this Sandbox in one of two forms
125
+ # ({docs/behavior.md B-32}[link:../../docs/behavior.md]):
114
126
  #
115
- # Source delivery uses the WASI stdin two-frame protocol
116
- # ({SPEC.md ABI Signatures}[link:../../SPEC.md]): Frame 1 carries the
117
- # msgpack-encoded preamble (Namespace / Member registry snapshot) and Frame 2
118
- # carries the user script UTF-8 bytes. Each frame is prefixed by a
119
- # 4-byte big-endian u32 length.
127
+ # * +preload(code: source, name: Name)+ +source+ is mruby source
128
+ # as a +String+ and +Name+ matches +/\A[A-Z]\w*\z/+. The +name+
129
+ # becomes the snippet's +(snippet:Name)+ backtrace filename and
130
+ # is the dedupe key for E-33.
131
+ # * +preload(binary: bytes)+ +bytes+ is precompiled RITE
132
+ # bytecode as a +String+. The canonical name, when present,
133
+ # lives in the bytecode's embedded +debug_info+ and is resolved
134
+ # by the guest at load time; the host treats the bytes as
135
+ # opaque. Structural failures
136
+ # ({docs/behavior.md E-37 / E-38}[link:../../docs/behavior.md])
137
+ # surface as +Kobako::BytecodeError+ on the first invocation.
120
138
  #
121
- # Raises +Kobako::TrapError+ on a Wasm trap or wire-violation fallback;
122
- # +Kobako::SandboxError+ when the guest ran to completion but failed;
123
- # +Kobako::ServiceError+ on an unrescued Service capability failure.
124
- def run(source)
125
- raise SandboxError, "source must be a String, got #{source.class}" unless source.is_a?(String)
126
-
127
- @services.seal!
128
- reset_run_state!
139
+ # Subsequent invocations (+#eval+ or +#run+) replay every registered
140
+ # snippet in insertion order against the fresh +mrb_state+
141
+ # before per-invocation source or entrypoint resolution.
142
+ #
143
+ # Returns +self+ to allow chaining.
144
+ #
145
+ # Raises +ArgumentError+ when neither form's keyword set is
146
+ # supplied, when both forms are mixed (e.g., +code:+ and +binary:+
147
+ # together, or +binary:+ paired with +name:+), when +code+ / +bytes+
148
+ # is not a +String+, when +name+ does not match the constant
149
+ # pattern ({docs/behavior.md E-34}[link:../../docs/behavior.md]),
150
+ # when +name+ duplicates an already-registered +code:+ form snippet
151
+ # ({docs/behavior.md E-33}[link:../../docs/behavior.md]), or when
152
+ # called after the first invocation
153
+ # ({docs/behavior.md E-35, B-33}[link:../../docs/behavior.md]).
154
+ def preload(code: nil, name: nil, binary: nil)
155
+ raise ArgumentError, "cannot preload after first Sandbox invocation" if @services.sealed?
129
156
 
130
- run_guest(@services.encoded_preamble, source.b)
131
- read_captures!
132
- take_result!
157
+ @snippets.register(code: code, name: name, binary: binary)
158
+ self
133
159
  end
134
160
 
135
- private
136
-
137
- # Coerce +timeout+ into the Float seconds the ext expects, or +nil+ to
138
- # mean the cap is disabled ({SPEC.md B-01}[link:../../SPEC.md]). Any
139
- # finite non-positive value is rejected a zero or negative timeout
140
- # would either fire instantly or never, both of which would surprise
141
- # callers more than an early +ArgumentError+.
142
- def normalize_timeout(timeout)
143
- return nil if timeout.nil?
144
- raise ArgumentError, "timeout must be Numeric or nil, got #{timeout.class}" unless timeout.is_a?(Numeric)
145
-
146
- seconds = timeout.to_f
147
- raise ArgumentError, "timeout must be > 0 (got #{timeout})" unless seconds.positive? && seconds.finite?
148
-
149
- seconds
161
+ # Dispatch into a preloaded entrypoint constant
162
+ # ({docs/behavior.md B-31}[link:../../docs/behavior.md]). Delegates host
163
+ # pre-flight (E-24 / E-25 / E-29 / E-30) and wire encoding to
164
+ # +Kobako::Invocation+ / +Kobako::Invocation#encode+; the guest
165
+ # resolves +target+ as a top-level constant, calls +#call+ on it
166
+ # with +args+ / +kwargs+, and returns the deserialized result. The
167
+ # first invocation seals the Service registry and snippet table
168
+ # (B-07 / B-33). Runtime errors follow the same three-class taxonomy
169
+ # as +#eval+.
170
+ def run(target, *args, **kwargs)
171
+ invocation = Invocation.new(entrypoint: target, args: args, kwargs: kwargs)
172
+ invoke!(:run) do
173
+ @instance.run(@services.encoded_preamble, @snippets.encode, invocation.encode(@handle_table))
174
+ end
150
175
  end
151
176
 
152
- # Coerce +memory_limit+ into the byte cap the ext expects, or +nil+ to
153
- # mean unbounded ({SPEC.md B-01, E-20}[link:../../SPEC.md]). Must be a
154
- # positive Integer when set; +Float+ or zero/negative values are
155
- # rejected.
156
- def normalize_memory_limit(memory_limit)
157
- return nil if memory_limit.nil?
158
- unless memory_limit.is_a?(Integer) && memory_limit.positive?
159
- raise ArgumentError, "memory_limit must be a positive Integer or nil, got #{memory_limit.inspect}"
160
- end
177
+ # Execute a guest mruby source string in a fresh +mrb_state+
178
+ # ({docs/behavior.md B-02 / B-03 / B-06}[link:../../docs/behavior.md]). +code+ is the
179
+ # mruby source as a UTF-8 String. Returns the deserialized last
180
+ # expression of the source.
181
+ #
182
+ # Source delivery uses the WASI stdin three-frame protocol
183
+ # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]):
184
+ # Frame 1 carries the msgpack-encoded preamble (Namespace / Member
185
+ # registry snapshot), Frame 2 carries the user source UTF-8 bytes, and
186
+ # Frame 3 carries the snippet table registered via +#preload+ (B-32).
187
+ # Each frame is prefixed by a 4-byte big-endian u32 length; Frame 3 is
188
+ # mandatory-presence — an empty snippet table sends an empty msgpack
189
+ # array, never an absent frame.
190
+ #
191
+ # The first invocation seals the Service registry and snippet table
192
+ # ({docs/behavior.md B-07 / B-33}[link:../../docs/behavior.md]); subsequent
193
+ # +#define+ / +#preload+ calls raise +ArgumentError+.
194
+ #
195
+ # Raises +Kobako::TrapError+ on a Wasm trap or wire-violation fallback;
196
+ # +Kobako::SandboxError+ when the guest ran to completion but failed
197
+ # (including when +code+ is +nil+ or not a String, or when a preloaded
198
+ # snippet's replay raises — E-36);
199
+ # +Kobako::ServiceError+ on an unrescued Service capability failure.
200
+ def eval(code)
201
+ raise SandboxError, "code must be a String, got #{code.class}" unless code.is_a?(String)
161
202
 
162
- memory_limit
203
+ invoke!(:eval) do
204
+ @instance.eval(@services.encoded_preamble, code.b, @snippets.encode)
205
+ end
163
206
  end
164
207
 
165
- # Per-run state reset ({SPEC.md B-03}[link:../../SPEC.md]). Capture
166
- # buffers, truncation predicates, and the HandleTable counter are
167
- # zeroed before the guest runs.
168
- def reset_run_state!
169
- @services.reset_handles!
170
- clear_captures!
208
+ private
209
+
210
+ # Per-invocation prologue ({docs/behavior.md B-03 / B-07 /
211
+ # B-33}[link:../../docs/behavior.md]). Seals the Service / snippet
212
+ # registries on first call (idempotent) and zeros the per-invocation
213
+ # capability state — capture buffers, truncation predicates, and the
214
+ # HandleTable counter — before the guest runs. The HandleTable
215
+ # itself is held as +@handle_table+ and never exposed beyond
216
+ # this class: SPEC.md Terminology pins it as "Not exposed to the
217
+ # Host App" (B-19 / B-20 / E-29).
218
+ def begin_invocation!
219
+ @services.seal!
220
+ @handle_table.reset!
221
+ reset_invocation_state!
171
222
  end
172
223
 
173
- # Reset both per-channel captures to the pre-run sentinel
174
- # ({SPEC.md B-05}[link:../../SPEC.md]). Shared by +#initialize+
175
- # (first-run setup) and +#reset_run_state!+ (between-run reset) so
176
- # both paths agree on what "empty capture" means.
177
- def clear_captures!
224
+ # Reset all per-invocation observable state to its pre-invocation
225
+ # sentinels both per-channel captures
226
+ # ({docs/behavior.md B-05}[link:../../docs/behavior.md]) and the
227
+ # per-last-invocation usage record
228
+ # ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Shared by
229
+ # +#initialize+ (first-time setup) and +#begin_invocation!+
230
+ # (between-invocation reset) so both paths agree on what
231
+ # "pre-invocation state" means.
232
+ def reset_invocation_state!
178
233
  @stdout_capture = Capture::EMPTY
179
234
  @stderr_capture = Capture::EMPTY
235
+ @usage = Usage::EMPTY
180
236
  end
181
237
 
182
238
  # Read the per-channel capture pairs (+[bytes, truncated]+) from the
183
- # ext after a guest run completes and wrap each as a +Capture+ value
239
+ # ext after an invocation completes and wrap each as a +Capture+ value
184
240
  # object. The ext clips +bytes+ to the configured cap and sets
185
241
  # +truncated+ when the guest produced strictly more than +cap+ bytes
186
- # ({SPEC.md B-04}[link:../../SPEC.md]). Mirror of {#clear_captures!}
187
- # at the post-run boundary.
242
+ # ({docs/behavior.md B-04}[link:../../docs/behavior.md]). Mirror of
243
+ # {#reset_invocation_state!} at the post-invocation boundary.
188
244
  def read_captures!
189
245
  out_bytes, out_truncated = @instance.stdout
190
246
  err_bytes, err_truncated = @instance.stderr
@@ -192,37 +248,56 @@ module Kobako
192
248
  @stderr_capture = Capture.from_ext(err_bytes, err_truncated)
193
249
  end
194
250
 
195
- # Drive +Instance#run+ with the two stdin frames (preamble + source).
196
- # Wraps wasmtime / wire errors in TrapError so the Sandbox layer maps
197
- # cleanly to the three-class taxonomy. The configured-cap paths
198
- # (SPEC.md E-19 / E-20) are routed to the named TrapError subclasses
199
- # so callers that want to surface a specific reason can rescue them;
200
- # everything else falls through to the base TrapError.
201
- def run_guest(preamble, source)
202
- @instance.run(preamble, source)
203
- rescue Kobako::Wasm::TimeoutError => e
204
- raise TimeoutError, "guest exceeded timeout: #{e.message}"
205
- rescue Kobako::Wasm::MemoryLimitError => e
206
- raise MemoryLimitError, "guest exceeded memory_limit: #{e.message}"
207
- rescue Kobako::Wasm::Error => e
208
- raise TrapError, "guest __kobako_run trapped: #{e.message}"
251
+ # Read the per-last-invocation +wall_time+ and +memory_peak+ from
252
+ # the ext and wrap them as a +Kobako::Usage+ value object
253
+ # ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Runs in
254
+ # the +invoke!+ +ensure+ block so the usage record is populated on
255
+ # every outcome value return, +Kobako::TrapError+ (including
256
+ # +TimeoutError+ / +MemoryLimitError+), +Kobako::SandboxError+,
257
+ # and +Kobako::ServiceError+.
258
+ #
259
+ # The ext returns a positional 2-tuple +[wall_time, memory_peak]+
260
+ # whose order matches the +Kobako::Usage+ field order; the
261
+ # destructure-then-kwargs handoff below is the explicit
262
+ # positional→keyword conversion point, mirroring
263
+ # +#read_captures!+'s +Capture.from_ext(bytes, truncated)+ shape.
264
+ def read_usage!
265
+ wall_time, memory_peak = @instance.usage
266
+ @usage = Usage.new(wall_time: wall_time, memory_peak: memory_peak)
209
267
  end
210
268
 
211
- # Take OUTCOME_BUFFER bytes from guest memory via +Instance#outcome!+
212
- # and decode them into the Sandbox-level result — the unwrapped mruby
213
- # return value, or a raised three-layer
214
- # ({SPEC.md "Error Scenarios"}[link:../../SPEC.md]) exception. A zero-
215
- # length outcome is delivered to +Kobako::Outcome+ as an empty String
216
- # so a single boundary attributes every wire-violation outcome
217
- # ({SPEC.md ABI Signatures}[link:../../SPEC.md]).
218
- #
219
- # The bang reflects the destructive ext call beneath: the underlying
220
- # +__kobako_take_outcome+ export invalidates the buffer pointer, so this
221
- # method must be called at most once per +#run+.
222
- def take_result!
269
+ # Map a wasmtime trap class to the matching three-layer Ruby
270
+ # exception class. Cap-trap subclasses
271
+ # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md])
272
+ # select their named +TrapError+ subclass; everything else
273
+ # collapses to the base +Kobako::TrapError+.
274
+ def trap_class_for(err)
275
+ case err
276
+ when Kobako::Wasm::TimeoutError then TimeoutError
277
+ when Kobako::Wasm::MemoryLimitError then MemoryLimitError
278
+ else TrapError
279
+ end
280
+ end
281
+
282
+ # Shared prologue / epilogue + trap-class translator for both
283
+ # invocation verbs. +verb+ is +:eval+ or +:run+; it tags the
284
+ # TrapError message so the failing export is identifiable. The
285
+ # rescue chain is the single trap-translation boundary — wasmtime /
286
+ # wire failures from the guest call and from the subsequent
287
+ # +Instance#outcome!+ read both flow through here, so an
288
+ # OUTCOME_BUFFER read failure attributes to the same export name as
289
+ # the guest call itself. Configured-cap paths
290
+ # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md]) surface as
291
+ # named TrapError subclasses.
292
+ def invoke!(verb)
293
+ begin_invocation!
294
+ yield
295
+ read_captures!
223
296
  Outcome.decode(@instance.outcome!)
224
297
  rescue Kobako::Wasm::Error => e
225
- raise TrapError, "failed to read OUTCOME_BUFFER: #{e.message}"
298
+ raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
299
+ ensure
300
+ read_usage!
226
301
  end
227
302
  end
228
303
  end