security_box 0.3.0 → 0.6.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.
@@ -0,0 +1,31 @@
1
+ PATH
2
+ remote: sb_rpc
3
+ specs:
4
+ sb_rpc (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ logger (1.7.0)
10
+ ruby_wasm (2.10.1)
11
+ logger
12
+ ruby_wasm (2.10.1-x86_64-linux)
13
+ logger
14
+
15
+ PLATFORMS
16
+ ruby
17
+ x86_64-linux
18
+
19
+ DEPENDENCIES
20
+ ruby_wasm (~> 2.10.1)
21
+ sb_rpc!
22
+
23
+ CHECKSUMS
24
+ bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
25
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
26
+ ruby_wasm (2.10.1) sha256=874e0acb783a8326bfb0db892004816c26963a4276a9f719791a8828e53a05f1
27
+ ruby_wasm (2.10.1-x86_64-linux) sha256=728e29dc688246572b56dcef1c7b131431d07865320c9b8434dd1ccdfd63db2b
28
+ sb_rpc (0.1.0)
29
+
30
+ BUNDLED WITH
31
+ 4.0.20
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+
5
+ create_makefile("sb_rpc")
@@ -0,0 +1,29 @@
1
+ #include <ruby.h>
2
+ #include <stdint.h>
3
+
4
+ /*
5
+ * Host bridge for security_box RPC.
6
+ *
7
+ * sb_rpc_call is resolved as a wasm import by the host
8
+ * (Wasmtime::Linker#func_new, module "sb", function "call"). The guest
9
+ * blocks inside this call while the host executes the registered handler;
10
+ * the host returns 0 on success (any other value signals a transport
11
+ * failure). The request/response payload travels via /work JSON files
12
+ * written/read by the guest-side Ruby wrapper (sb_rpc.rb in the image),
13
+ * keeping this C code free of any protocol knowledge.
14
+ */
15
+ __attribute__((import_module("sb"), import_name("call")))
16
+ extern int32_t sb_rpc_call(void);
17
+
18
+ static VALUE
19
+ sb_call(VALUE self)
20
+ {
21
+ return INT2NUM(sb_rpc_call());
22
+ }
23
+
24
+ void
25
+ Init_sb_rpc(void)
26
+ {
27
+ VALUE mSBExt = rb_define_module("SBExt");
28
+ rb_define_singleton_method(mSBExt, "call", sb_call, 0);
29
+ }
@@ -0,0 +1,6 @@
1
+ # Loaded by `require "sb_rpc"` inside the security_box guest image.
2
+ #
3
+ # The extension is statically linked into the ruby.wasm binary; requiring
4
+ # the .so feature name triggers its Init_sb_rpc hook through the static
5
+ # extension registry.
6
+ require "sb_rpc.so"
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "sb_rpc"
5
+ spec.version = "0.1.0"
6
+ spec.summary = "Host RPC bridge for security_box guest images"
7
+ spec.description =
8
+ "Exposes SBExt.call, a wasm import bridge the guest uses to invoke " \
9
+ "host-registered RPC handlers. The call blocks inside the sandbox while " \
10
+ "the host executes the handler; the request/response travel via JSON " \
11
+ "files in /work."
12
+ spec.authors = ["security_box"]
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.0"
15
+ spec.files = Dir["lib/**/*.rb", "ext/**/*.{c,h,rb}"] + ["sb_rpc.gemspec"]
16
+ spec.extensions = ["ext/sb_rpc/extconf.rb"]
17
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SecurityBox
6
+ # Host side of the guest RPC channel (stage 6).
7
+ #
8
+ # The image statically links the sb_rpc gem, whose C extension declares
9
+ # the wasm import ("sb", "call"). The linker must define it for every
10
+ # instantiation — even when no handlers are configured, or instantiation
11
+ # fails on an unresolved import. Per-eval state (the /work tmpdir, the
12
+ # handler map and the call transcript) travels through Store data and
13
+ # reaches the closure via caller.store_data, so a single definition is
14
+ # safe to share across evaluations, threads and Ractor workers.
15
+ #
16
+ # Protocol (guest side: lib/security_box/guest/rpc.rb):
17
+ # guest writes /work/rpc_req.json {"name", "args"}
18
+ # guest calls SBExt.call (blocks inside the import)
19
+ # host executes handlers[name], writes /work/rpc_resp.json
20
+ # host returns 0 (any non-zero value is a transport failure)
21
+ #
22
+ # Nothing escapes the closure: a raising handler (or any failure in the
23
+ # bridge) becomes an {"ok": false, "error": {"class", "message"}}
24
+ # response the guest can rescue. Messages never include host details
25
+ # (no backtraces, no paths). Handler results are JSON round-tripped,
26
+ # like guest return values — never Marshal.
27
+ module GuestRpc
28
+ IMPORT_MODULE = "sb"
29
+ IMPORT_NAME = "call"
30
+ REQUEST_FILE = "rpc_req.json"
31
+ RESPONSE_FILE = "rpc_resp.json"
32
+ MAX_CALLS = 1_000
33
+ RESULT_LIMIT = 1 << 20
34
+
35
+ class << self
36
+ # Defines the import on `linker` (once per linker; state is
37
+ # per-Store). The closure captures nothing — Ractor-safe.
38
+ def define_import(linker)
39
+ linker.func_new(IMPORT_MODULE, IMPORT_NAME, [], [:i32]) do |caller|
40
+ serve(caller)
41
+ end
42
+ nil
43
+ end
44
+
45
+ # Per-eval Store data. `handlers` is the configuration's
46
+ # name => callable map, or nil when no rpcs are configured (guest
47
+ # calls still get a clean, rescuable error).
48
+ def store_data(workdir, handlers)
49
+ {
50
+ workdir: workdir,
51
+ rpc: handlers.nil? ? nil : { handlers: handlers, calls: [] }
52
+ }
53
+ end
54
+
55
+ private
56
+
57
+ def serve(caller)
58
+ data = caller.store_data
59
+ rpc = data[:rpc]
60
+ unless rpc
61
+ return fail_call(data, nil, "SB::UnknownTool",
62
+ "no RPC handlers are configured for this sandbox")
63
+ end
64
+
65
+ request = JSON.parse(File.read(request_path(data)))
66
+ entry = { "name" => request["name"], "args" => request["args"] }
67
+ rpc[:calls] << entry
68
+ dispatch(data, rpc, request, entry)
69
+ 0
70
+ rescue Exception => e # rubocop:disable Lint/RescueException
71
+ # Transport/boundary failure (unparsable request, broken /work):
72
+ # encoded as a tool error so the guest gets a rescuable exception
73
+ # instead of the host crashing through the wasm boundary.
74
+ fail_call(data, nil, e.class.name, e.message.to_s)
75
+ end
76
+
77
+ def dispatch(data, rpc, request, entry)
78
+ if rpc[:calls].size > MAX_CALLS
79
+ return fail_call(data, entry, "SB::ToolError",
80
+ "too many RPC calls (limit #{MAX_CALLS})")
81
+ end
82
+
83
+ handler = rpc[:handlers][request["name"]]
84
+ if handler.nil?
85
+ return fail_call(data, entry, "SB::UnknownTool",
86
+ "unknown rpc: #{request["name"]}")
87
+ end
88
+
89
+ result = jsonable(handler.call(request["args"]))
90
+ payload = { "ok" => true, "result" => result }
91
+ if JSON.generate(payload).bytesize > RESULT_LIMIT
92
+ return fail_call(data, entry, "SB::ToolError",
93
+ "rpc result too large (limit #{RESULT_LIMIT} bytes)")
94
+ end
95
+
96
+ entry["ok"] = true
97
+ entry["result"] = result
98
+ write_response(data, payload)
99
+ rescue Exception => e # rubocop:disable Lint/RescueException
100
+ fail_call(data, entry, e.class.name, e.message.to_s)
101
+ end
102
+
103
+ def fail_call(data, entry, klass, message)
104
+ error = { "class" => klass, "message" => message }
105
+ # The entry (when present) is already in the transcript — mutate
106
+ # it in place instead of pushing a duplicate.
107
+ if entry
108
+ entry["ok"] = false
109
+ entry["error"] = error
110
+ end
111
+ write_response(data, { "ok" => false, "error" => error })
112
+ 0
113
+ end
114
+
115
+ def request_path(data)
116
+ File.join(data[:workdir], REQUEST_FILE)
117
+ end
118
+
119
+ def write_response(data, payload)
120
+ File.write(File.join(data[:workdir], RESPONSE_FILE), JSON.generate(payload))
121
+ end
122
+
123
+ # Non-serializable handler results surface as inspect strings, like
124
+ # guest return values (PLAN.md: never Marshal guest/host payloads).
125
+ def jsonable(value)
126
+ JSON.parse(JSON.generate(value))
127
+ rescue StandardError, TypeError
128
+ value.inspect
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecurityBox
4
+ # Bounded pool of :oneshot Sandboxes.
5
+ #
6
+ # What it gives you:
7
+ # - a hard cap on concurrent sandbox evaluations (`size`);
8
+ # - cheap checkout/checkin of pre-built Sandbox objects;
9
+ # - basic metrics (evals, total/average wall time).
10
+ #
11
+ # What it does NOT give you (measured in stage 4, docs/plan/stages/stage_4.md):
12
+ # parallelism. `invoke` holds the GVL for the whole guest lifetime, so
13
+ # concurrent evals on threads serialize. For real parallelism use
14
+ # RactorPool, which runs workers on Ractors with a shared Engine+Module.
15
+ #
16
+ # Usage:
17
+ # pool = SecurityBox::Pool.new(:lean, size: 4)
18
+ # pool.eval("1 + 1") # => Result (checks out a sandbox)
19
+ # pool.checkout { |sandbox| ... } # manual checkout block
20
+ # pool.shutdown
21
+ class Pool
22
+ attr_reader :size
23
+
24
+ def initialize(profile = nil, size: 4, **options)
25
+ @size = Integer(size)
26
+ raise ArgumentError, "size must be >= 1" if @size < 1
27
+
28
+ @config = resolve_config(profile, options)
29
+ @sandboxes = Queue.new
30
+ @mutex = Mutex.new
31
+ @created = 0
32
+ @closed = false
33
+ @metrics = { evals: 0, total_ms: 0.0 }
34
+ end
35
+
36
+ # Checks a sandbox out of the pool for the duration of the block.
37
+ # Blocks while all `size` sandboxes are busy.
38
+ def checkout
39
+ raise PoolClosed, "pool is closed" if closed?
40
+
41
+ sandbox = acquire
42
+ begin
43
+ yield sandbox
44
+ ensure
45
+ release(sandbox)
46
+ end
47
+ end
48
+
49
+ # Checks a sandbox out and runs `code` on it. Per-call overrides are
50
+ # forwarded to Sandbox#eval.
51
+ def eval(code, **overrides)
52
+ t0 = monotonic_ms
53
+ result = checkout { |sandbox| sandbox.eval(code, **overrides) }
54
+ record(monotonic_ms - t0)
55
+ result
56
+ end
57
+
58
+ # Snapshot of the counters:
59
+ # {size:, created:, evals:, total_ms:, avg_ms:}.
60
+ def metrics
61
+ @mutex.synchronize do
62
+ evals = @metrics[:evals]
63
+ { size: @size, created: @created, evals: evals,
64
+ total_ms: @metrics[:total_ms].round(2),
65
+ avg_ms: evals.zero? ? 0.0 : (@metrics[:total_ms] / evals).round(2) }
66
+ end
67
+ end
68
+
69
+ def closed?
70
+ @mutex.synchronize { @closed }
71
+ end
72
+
73
+ # Closes the pool. Sandboxes currently checked out keep working until
74
+ # released; threads blocked on an empty pool keep waiting (callers must
75
+ # ensure they do not shut down while checkouts are still being awaited).
76
+ def shutdown
77
+ @mutex.synchronize { @closed = true }
78
+ end
79
+
80
+ private
81
+
82
+ def resolve_config(profile, options)
83
+ case profile
84
+ when nil then Configuration.build(**options)
85
+ when Symbol, String then Registry.resolve(profile).with(**options)
86
+ when Configuration then profile.with(**options)
87
+ else raise ArgumentError, "profile must be a registered name or a Configuration, got #{profile.class}"
88
+ end
89
+ end
90
+
91
+ # Hands out an existing sandbox, creates a new one while under `size`, or
92
+ # blocks until another caller releases one. Sandbox creation happens
93
+ # outside the mutex (it may raise, e.g. ImageMissing).
94
+ def acquire
95
+ create = false
96
+ @mutex.synchronize do
97
+ raise PoolClosed, "pool is closed" if @closed
98
+
99
+ if @created < @size
100
+ @created += 1
101
+ create = true
102
+ end
103
+ end
104
+
105
+ if create
106
+ begin
107
+ return Sandbox.new(@config)
108
+ rescue StandardError
109
+ @mutex.synchronize { @created -= 1 }
110
+ raise
111
+ end
112
+ end
113
+
114
+ # All created sandboxes are busy: block until one is released. (The
115
+ # closed check here mirrors checkout's — a shutdown racing an acquire
116
+ # is still resolved by the release that eventually unblocks this pop.)
117
+ sandbox = @sandboxes.pop
118
+ raise PoolClosed, "pool is closed" if closed?
119
+
120
+ sandbox
121
+ end
122
+
123
+ def release(sandbox)
124
+ @sandboxes << sandbox
125
+ end
126
+
127
+ def record(duration_ms)
128
+ @mutex.synchronize do
129
+ @metrics[:evals] += 1
130
+ @metrics[:total_ms] += duration_ms
131
+ end
132
+ end
133
+
134
+ def monotonic_ms
135
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SecurityBox
6
+ # Pool of Ractor workers sharing one Engine + compiled Module (stage-3 Q2
7
+ # "Plan C"): the pool builds a dedicated runtime pair, makes it
8
+ # Ractor-shareable, and N worker Ractors each keep a private Linker. Per
9
+ # eval a worker creates Store + Instance (~0.2ms), applies the request's
10
+ # fuel/epoch/memory limits (safe: the worker owns its store), and invokes.
11
+ #
12
+ # This is the only supported way to run evaluations in parallel: `invoke`
13
+ # holds the GVL for the whole guest lifetime (stage-4 Q1), so threads
14
+ # serialize (see Pool).
15
+ #
16
+ # Protocol (Ruby 3.4+/4.0 Ractor port model):
17
+ # - eval builds a request {id, code, token, config} and sends it to the
18
+ # worker with the fewest in-flight requests (worker << request);
19
+ # - workers run the shared EvalRun core and push
20
+ # {id, worker, result} back to the main Ractor (Ractor.main << ...);
21
+ # - a main-side collector thread receives from the main Ractor port and
22
+ # routes each result to the Queue of the thread waiting for it (results
23
+ # are matched by request id; this thread must be the only main-port
24
+ # receiver in the process);
25
+ # - every request terminates (epoch + fuel), and a pop deadline converts a
26
+ # dead worker into a :sandbox_error result instead of a hang.
27
+ #
28
+ # Trade-offs (measured, stage-3 Q2 / stage-4): one module deserialize per
29
+ # pool (~0.5s via the disk cache); each worker's eval still pays the
30
+ # ~240ms guest boot. Ratios at 2/4 Ractors on 6 cores: 0.56 / 0.28 wall vs
31
+ # serial, RSS ~111MB with the shared runtime.
32
+ #
33
+ # Usage:
34
+ # pool = SecurityBox::RactorPool.new(:lean, size: 4)
35
+ # pool.eval("1 + 1") # => Result
36
+ # pool.shutdown
37
+ class RactorPool
38
+ STOP = :security_box_stop
39
+ POP_SLACK_MS = 10_000
40
+
41
+ attr_reader :size
42
+
43
+ def initialize(profile = nil, size: 4, **options)
44
+ @size = Integer(size)
45
+ raise ArgumentError, "size must be >= 1" if @size < 1
46
+
47
+ @config = resolve_config(profile, options)
48
+ raise InvalidConfiguration, "rpcs are not supported on RactorPool (handlers cannot cross a Ractor boundary)" unless @config.rpcs.empty?
49
+
50
+ @mutex = Mutex.new
51
+ @pending = {} # request id => Queue
52
+ @in_flight = Array.new(@size, 0)
53
+ @closed = false
54
+ @next_id = 0
55
+ @metrics = { evals: 0, total_ms: 0.0, timeouts: 0 }
56
+
57
+ engine, module_ = Runtime.build_shareable(
58
+ image_path: @config.image_path,
59
+ epoch_interval_ms: @config.epoch_interval_ms
60
+ )
61
+ @workers = Array.new(@size) do |index|
62
+ build_worker(engine, module_, index)
63
+ end
64
+ start_collector_thread
65
+ end
66
+
67
+ # Runs `code` on one of the workers and blocks until its Result arrives.
68
+ # Per-call overrides follow Sandbox#eval (same Configuration#with rules).
69
+ #
70
+ # RPC handlers are not supported here (v1): they are host Procs that
71
+ # cannot cross a Ractor boundary. A configuration with rpcs raises
72
+ # InvalidConfiguration at construction.
73
+ def eval(code, **overrides)
74
+ raise PoolClosed, "pool is closed" if closed?
75
+
76
+ config = overrides.empty? ? @config : @config.with(**overrides)
77
+ raise InvalidConfiguration, "rpcs are not supported on RactorPool (handlers cannot cross a Ractor boundary)" unless config.rpcs.empty?
78
+
79
+ request = {
80
+ id: next_id,
81
+ code: code,
82
+ token: SecureRandom.hex(16),
83
+ config: config
84
+ }
85
+ queue = Queue.new
86
+ worker_index = @mutex.synchronize do
87
+ @pending[request[:id]] = queue
88
+ index = @in_flight.each_with_index.min_by { |count, _| count }.last
89
+ @in_flight[index] += 1
90
+ index
91
+ end
92
+
93
+ begin
94
+ @workers[worker_index] << request
95
+ deadline_ms = config.timeout_ms + POP_SLACK_MS
96
+ result = queue.pop(timeout: deadline_ms / 1000.0)
97
+ raise ThreadError, "no result" if result.nil? # Queue#pop timeout
98
+ record(result)
99
+ result
100
+ rescue ThreadError, Ractor::ClosedError
101
+ # Worker died, stalled beyond the deadline, or was shut down mid-flight:
102
+ # report instead of hang or leak the error.
103
+ @mutex.synchronize { @metrics[:timeouts] += 1 }
104
+ Result.worker_unavailable(worker_index)
105
+ ensure
106
+ @mutex.synchronize do
107
+ @in_flight[worker_index] -= 1 if @in_flight[worker_index] > 0
108
+ @pending.delete(request[:id])
109
+ end
110
+ end
111
+ end
112
+
113
+ # Snapshot of the counters: {size:, evals:, total_ms:, avg_ms:, timeouts:}.
114
+ def metrics
115
+ @mutex.synchronize do
116
+ evals = @metrics[:evals]
117
+ { size: @size, evals: evals, total_ms: @metrics[:total_ms].round(2),
118
+ avg_ms: evals.zero? ? 0.0 : (@metrics[:total_ms] / evals).round(2),
119
+ timeouts: @metrics[:timeouts] }
120
+ end
121
+ end
122
+
123
+ def closed?
124
+ @mutex.synchronize { @closed }
125
+ end
126
+
127
+ # Asks all workers to stop. Evals already in flight finish; new evals
128
+ # raise PoolClosed.
129
+ def shutdown
130
+ @mutex.synchronize { @closed = true }
131
+ @workers.each { |worker| worker << STOP }
132
+ end
133
+
134
+ private
135
+
136
+ def resolve_config(profile, options)
137
+ case profile
138
+ when nil then Configuration.build(**options)
139
+ when Symbol, String then Registry.resolve(profile).with(**options)
140
+ when Configuration then profile.with(**options)
141
+ else raise ArgumentError, "profile must be a registered name or a Configuration, got #{profile.class}"
142
+ end
143
+ end
144
+
145
+ def build_worker(engine, module_, worker_index)
146
+ Ractor.new(engine, module_, worker_index, name: "security_box-worker-#{worker_index}") do |eng, mod, index|
147
+ linker = Wasmtime::Linker.new(eng).tap do |l|
148
+ Wasmtime::WASI::P1.add_to_linker_sync(l)
149
+ # The image statically links the sb_rpc extension, so the
150
+ # "sb"/"call" import must be defined even though configs are
151
+ # stripped of rpcs before crossing the Ractor boundary
152
+ # (guest calls get a rescuable error via caller.store_data).
153
+ SecurityBox::GuestRpc.define_import(l)
154
+ end
155
+ loop do
156
+ request = Ractor.receive
157
+ break if request == :security_box_stop
158
+
159
+ begin
160
+ result = SecurityBox::EvalRun.run(
161
+ engine: eng, module_: mod, linker: linker,
162
+ config: request[:config], code: request[:code], token: request[:token]
163
+ )
164
+ rescue StandardError => e
165
+ result = SecurityBox::Result.new(
166
+ status: :sandbox_error,
167
+ stderr: "security_box: worker #{index} crashed: #{e.class}: #{e.message}\n"
168
+ )
169
+ end
170
+ Ractor.main << { id: request[:id], worker: index, result: result }
171
+ end
172
+ Ractor.main << { type: :stopped, worker: index }
173
+ end
174
+ end
175
+
176
+ def start_collector_thread
177
+ @collector_thread = Thread.new do
178
+ stopped = 0
179
+ loop do
180
+ message = Ractor.receive
181
+ if message[:type] == :stopped
182
+ stopped += 1
183
+ break if stopped >= @size
184
+ else
185
+ route(message)
186
+ end
187
+ end
188
+ end
189
+ @collector_thread.name = "security_box-collector"
190
+ end
191
+
192
+ def route(message)
193
+ @mutex.synchronize do
194
+ @in_flight[message[:worker]] -= 1 if @in_flight[message[:worker]] > 0
195
+ queue = @pending.delete(message[:id])
196
+ queue << message[:result] if queue
197
+ end
198
+ end
199
+
200
+ def record(result)
201
+ @mutex.synchronize do
202
+ @metrics[:evals] += 1
203
+ @metrics[:total_ms] += result.duration_ms.to_f
204
+ end
205
+ end
206
+
207
+ def next_id
208
+ @mutex.synchronize do
209
+ @next_id += 1
210
+ @next_id
211
+ end
212
+ end
213
+ end
214
+ end
@@ -21,7 +21,12 @@ module SecurityBox
21
21
 
22
22
  builder = Configuration::Builder.new
23
23
  block&.call(builder)
24
- @profiles[key] = Configuration.build(**base_of(from), **builder.changes)
24
+ base = base_of(from)
25
+ if base[:fuel_ms] && builder.changes.key?(:fuel)
26
+ raise InvalidConfiguration,
27
+ "base profile #{from.inspect} uses fuel_ms; override fuel_ms (or clear it with fuel_ms: nil) instead of fuel"
28
+ end
29
+ @profiles[key] = Configuration.build(**base, **builder.changes)
25
30
  end
26
31
  end
27
32
 
@@ -8,12 +8,17 @@ module SecurityBox
8
8
  # :fuel_exhausted — CPU budget exhausted
9
9
  # :memory_limit — exceeded the store memory_size
10
10
  # :sandbox_error — sandbox failure (unexpected trap, missing/invalid envelope)
11
+ #
12
+ # `rpcs` (stage 6): the host-side transcript of guest RPC calls, one
13
+ # frozen {"name", "args", "ok", "result"|"error"} entry per call, or nil
14
+ # when no rpcs were configured (or none were called).
11
15
  class Result
12
16
  attr_reader :status, :value, :error, :stdout, :stderr,
13
- :fuel_used, :duration_ms, :guest_duration_ms
17
+ :fuel_used, :duration_ms, :guest_duration_ms, :rpcs
14
18
 
15
19
  def initialize(status:, value: nil, error: nil, stdout: "", stderr: "",
16
- fuel_used: nil, duration_ms: nil, guest_duration_ms: nil)
20
+ fuel_used: nil, duration_ms: nil, guest_duration_ms: nil,
21
+ rpcs: nil)
17
22
  @status = status
18
23
  @value = value
19
24
  @error = error
@@ -22,6 +27,7 @@ module SecurityBox
22
27
  @fuel_used = fuel_used
23
28
  @duration_ms = duration_ms
24
29
  @guest_duration_ms = guest_duration_ms
30
+ @rpcs = rpcs
25
31
  freeze
26
32
  end
27
33
 
@@ -29,6 +35,13 @@ module SecurityBox
29
35
  @status == :ok
30
36
  end
31
37
 
38
+ # Factory for the "the worker never answered" sandbox failure (dead or
39
+ # stalled Ractor worker, or the pop deadline elapsed).
40
+ def self.worker_unavailable(worker_index)
41
+ new(status: :sandbox_error,
42
+ stderr: "security_box: RactorPool worker #{worker_index} did not answer\n")
43
+ end
44
+
32
45
  def to_h
33
46
  {
34
47
  status: @status,
@@ -38,7 +51,8 @@ module SecurityBox
38
51
  stderr: @stderr,
39
52
  fuel_used: @fuel_used,
40
53
  duration_ms: @duration_ms,
41
- guest_duration_ms: @guest_duration_ms
54
+ guest_duration_ms: @guest_duration_ms,
55
+ rpcs: @rpcs
42
56
  }
43
57
  end
44
58
  end
@@ -41,6 +41,23 @@ module SecurityBox
41
41
  end
42
42
  end
43
43
 
44
+ # Builds a dedicated (not memoized) Engine + Module pair prepared for
45
+ # use across Ractors: the epoch timer is started and the precompile
46
+ # key is touched BEFORE freezing, then both artifacts are made
47
+ # Ractor-shareable (stage-3 Q2 "Plan C"). The pair is not stored in the
48
+ # class memos — freezing shared artifacts would affect every Sandbox —
49
+ # so each RactorPool pays one module deserialize (~0.5s via the disk
50
+ # cache). Callers must treat the returned pair as immutable.
51
+ def build_shareable(image_path:, epoch_interval_ms:)
52
+ engine = build_engine(epoch_interval_ms)
53
+ module_ = ModuleCache.load_module(engine, image_path) ||
54
+ ModuleCache.compile_and_store(engine, image_path)
55
+ engine.precompile_compatibility_key # touch before make_shareable
56
+ Ractor.make_shareable(engine)
57
+ Ractor.make_shareable(module_)
58
+ [engine, module_]
59
+ end
60
+
44
61
  private
45
62
 
46
63
  def build_engine(epoch_interval_ms)