security_box 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -0
- data/README.md +115 -9
- data/lib/security_box/configuration.rb +193 -6
- data/lib/security_box/errors.rb +2 -0
- data/lib/security_box/eval_run.rb +191 -0
- data/lib/security_box/pool.rb +138 -0
- data/lib/security_box/ractor_pool.rb +199 -0
- data/lib/security_box/registry.rb +6 -1
- data/lib/security_box/result.rb +7 -0
- data/lib/security_box/runtime.rb +17 -0
- data/lib/security_box/sandbox.rb +22 -151
- data/lib/security_box/version.rb +1 -1
- data/lib/security_box.rb +18 -0
- metadata +4 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module SecurityBox
|
|
8
|
+
# One :oneshot evaluation against a pre-built runtime (Engine + Module +
|
|
9
|
+
# Linker). This is the shared core used by Sandbox (main Ractor, threads)
|
|
10
|
+
# and by RactorPool workers (one Ractor per worker) — both follow the same
|
|
11
|
+
# protocol: sandbox-exclusive tmpdir mounted as /work, per-eval token via
|
|
12
|
+
# ENV, envelope read back from /work/out.json (stdout sentinel fallback),
|
|
13
|
+
# fuel + epoch deadlines per evaluation, and explicit read-only-by-default
|
|
14
|
+
# host-folder mounts (stage 5) validated per eval.
|
|
15
|
+
#
|
|
16
|
+
# Everything here is self-contained per call: no shared mutable state, safe
|
|
17
|
+
# to run concurrently from multiple Ractors/threads as long as each caller
|
|
18
|
+
# owns its Engine/Module/Linker (Ractors) or shares them thread-safely
|
|
19
|
+
# (wasmtime Engine/Module/Linker are thread-safe).
|
|
20
|
+
module EvalRun
|
|
21
|
+
GUEST_ENTRYPOINT = "/src/main.rb"
|
|
22
|
+
CODE_FILE = "code.rb"
|
|
23
|
+
RESULT_FILE = "out.json"
|
|
24
|
+
TOKEN_ENV_VAR = "SB_TOKEN"
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Runs `code` once and returns a Result. `config` is a Configuration
|
|
28
|
+
# (read-only); `token` is the per-eval random token generated by the
|
|
29
|
+
# caller (SecureRandom is not guaranteed Ractor-safe, so workers
|
|
30
|
+
# receive it pre-generated).
|
|
31
|
+
def run(engine:, module_:, linker:, config:, code:, token:)
|
|
32
|
+
stdout = +""
|
|
33
|
+
stderr = +""
|
|
34
|
+
t0 = monotonic_ms
|
|
35
|
+
|
|
36
|
+
# Mounts reference host paths that can disappear between building the
|
|
37
|
+
# configuration and this eval; fail here with a clear note instead of
|
|
38
|
+
# letting Wasmtime::Store.new raise an opaque error mid-setup.
|
|
39
|
+
mount_errors = mount_errors(config)
|
|
40
|
+
unless mount_errors.empty?
|
|
41
|
+
return Result.new(status: :sandbox_error, stderr: mount_errors.join("\n") << "\n",
|
|
42
|
+
duration_ms: monotonic_ms - t0)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Dir.mktmpdir("security_box") do |workdir|
|
|
46
|
+
File.write(File.join(workdir, CODE_FILE), code)
|
|
47
|
+
envelope = nil
|
|
48
|
+
status = nil
|
|
49
|
+
fuel_used = nil
|
|
50
|
+
|
|
51
|
+
store = nil
|
|
52
|
+
begin
|
|
53
|
+
store = Wasmtime::Store.new(
|
|
54
|
+
engine,
|
|
55
|
+
wasi_p1_config: build_wasi(workdir, stdout, stderr, config, token),
|
|
56
|
+
limits: { memory_size: config.memory_size }
|
|
57
|
+
)
|
|
58
|
+
store.set_fuel(config.effective_fuel)
|
|
59
|
+
instance = linker.instantiate(store, module_)
|
|
60
|
+
store.set_epoch_deadline(epoch_ticks(config))
|
|
61
|
+
status = invoke_guest(instance, store)
|
|
62
|
+
fuel_used = config.effective_fuel - store.get_fuel unless status == :timeout
|
|
63
|
+
envelope = read_envelope(workdir, stdout, token)
|
|
64
|
+
rescue Wasmtime::Error => e
|
|
65
|
+
# Instantiation can fail before the guest ever runs (e.g. a
|
|
66
|
+
# memory_size below the module's declared minimum pages).
|
|
67
|
+
status = :sandbox_error
|
|
68
|
+
stderr << "security_box: #{e.class}: #{e.message}\n"
|
|
69
|
+
ensure
|
|
70
|
+
store&.close
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
build_result(status, envelope, stdout, stderr, fuel_used, monotonic_ms - t0)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def build_wasi(workdir, stdout, stderr, config, token)
|
|
80
|
+
wasi = Wasmtime::WasiConfig.new
|
|
81
|
+
.set_stdin_string("")
|
|
82
|
+
.set_stdout_buffer(stdout, config.stdout_limit)
|
|
83
|
+
.set_stderr_buffer(stderr, config.stderr_limit)
|
|
84
|
+
.set_argv(["ruby", GUEST_ENTRYPOINT])
|
|
85
|
+
.set_env(config.env.merge(TOKEN_ENV_VAR => token))
|
|
86
|
+
.set_mapped_directory(workdir, "/work", :read_write)
|
|
87
|
+
# Explicit mounts come after /work; Configuration validation already
|
|
88
|
+
# guarantees no guest-path overlap with /work, /usr or /src.
|
|
89
|
+
config.mounts.reduce(wasi) do |wasi_config, mount|
|
|
90
|
+
wasi_config.set_mapped_directory(mount[:host], mount[:guest], mount[:mode])
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Host-side mount validation for this eval (configuration-level shape
|
|
95
|
+
# and collision rules are enforced in Configuration::Mounts).
|
|
96
|
+
def mount_errors(config)
|
|
97
|
+
config.mounts.reject { |mount| File.directory?(mount[:host]) }.map do |mount|
|
|
98
|
+
"security_box: mount #{mount[:host].inspect} (guest #{mount[:guest].inspect}): " \
|
|
99
|
+
"host path does not exist or is not a directory"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def epoch_ticks(config)
|
|
104
|
+
[config.timeout_ms / config.epoch_interval_ms + 1, 1].max
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def invoke_guest(instance, store)
|
|
108
|
+
instance.invoke("_start")
|
|
109
|
+
:ok
|
|
110
|
+
rescue Wasmtime::WasiExit => e
|
|
111
|
+
handle_wasi_exit(e, store)
|
|
112
|
+
rescue Wasmtime::Trap => e
|
|
113
|
+
map_trap(e, store)
|
|
114
|
+
rescue Wasmtime::Error
|
|
115
|
+
# Unexpected runtime error (non-trap): treated as a sandbox failure.
|
|
116
|
+
:sandbox_error
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def handle_wasi_exit(_exit_error, store)
|
|
120
|
+
# The guest rescues SystemExit; a WasiExit here means exit! or a guest
|
|
121
|
+
# crash before the envelope was written.
|
|
122
|
+
store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def map_trap(trap, store)
|
|
126
|
+
case trap.code
|
|
127
|
+
when :interrupt then :timeout
|
|
128
|
+
when :out_of_fuel then :fuel_exhausted
|
|
129
|
+
when :memory_out_of_bounds then :memory_limit
|
|
130
|
+
else
|
|
131
|
+
store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_envelope(workdir, stdout, token)
|
|
136
|
+
# Preferred channel: /work/out.json. Fallback: sentinel line on stdout.
|
|
137
|
+
# Both go through the same strict validation (schema + token).
|
|
138
|
+
Envelope.parse(read_result_file(workdir), token) ||
|
|
139
|
+
Envelope.from_stdout(stdout, token)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def read_result_file(workdir)
|
|
143
|
+
File.read(File.join(workdir, RESULT_FILE))
|
|
144
|
+
rescue SystemCallError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_result(status, envelope, stdout, stderr, fuel_used, duration_ms)
|
|
149
|
+
if envelope.nil?
|
|
150
|
+
status = :sandbox_error if status == :ok
|
|
151
|
+
return Result.new(
|
|
152
|
+
status: status, stdout: stdout, stderr: stderr,
|
|
153
|
+
fuel_used: fuel_used, duration_ms: duration_ms
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
guest_error = envelope["error"]
|
|
158
|
+
if envelope["ok"]
|
|
159
|
+
Result.new(
|
|
160
|
+
status: status, value: envelope["value"], stdout: stdout, stderr: stderr,
|
|
161
|
+
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
162
|
+
guest_duration_ms: envelope["duration_ms"]
|
|
163
|
+
)
|
|
164
|
+
elsif guest_memory_error?(guest_error)
|
|
165
|
+
# The guest hit the store memory limit through the Ruby interpreter
|
|
166
|
+
# (NoMemoryError instead of a wasm trap) — report it as a limit, not
|
|
167
|
+
# a user error.
|
|
168
|
+
Result.new(
|
|
169
|
+
status: :memory_limit, error: guest_error, stdout: stdout, stderr: stderr,
|
|
170
|
+
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
171
|
+
guest_duration_ms: envelope["duration_ms"]
|
|
172
|
+
)
|
|
173
|
+
else
|
|
174
|
+
Result.new(
|
|
175
|
+
status: :error, error: guest_error, stdout: stdout, stderr: stderr,
|
|
176
|
+
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
177
|
+
guest_duration_ms: envelope["duration_ms"]
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def guest_memory_error?(guest_error)
|
|
183
|
+
guest_error.is_a?(Hash) && guest_error["class"] == "NoMemoryError"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def monotonic_ms
|
|
187
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
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,199 @@
|
|
|
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
|
+
@mutex = Mutex.new
|
|
49
|
+
@pending = {} # request id => Queue
|
|
50
|
+
@in_flight = Array.new(@size, 0)
|
|
51
|
+
@closed = false
|
|
52
|
+
@next_id = 0
|
|
53
|
+
@metrics = { evals: 0, total_ms: 0.0, timeouts: 0 }
|
|
54
|
+
|
|
55
|
+
engine, module_ = Runtime.build_shareable(
|
|
56
|
+
image_path: @config.image_path,
|
|
57
|
+
epoch_interval_ms: @config.epoch_interval_ms
|
|
58
|
+
)
|
|
59
|
+
@workers = Array.new(@size) do |index|
|
|
60
|
+
build_worker(engine, module_, index)
|
|
61
|
+
end
|
|
62
|
+
start_collector_thread
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Runs `code` on one of the workers and blocks until its Result arrives.
|
|
66
|
+
# Per-call overrides follow Sandbox#eval (same Configuration#with rules).
|
|
67
|
+
def eval(code, **overrides)
|
|
68
|
+
raise PoolClosed, "pool is closed" if closed?
|
|
69
|
+
|
|
70
|
+
config = overrides.empty? ? @config : @config.with(**overrides)
|
|
71
|
+
request = {
|
|
72
|
+
id: next_id,
|
|
73
|
+
code: code,
|
|
74
|
+
token: SecureRandom.hex(16),
|
|
75
|
+
config: config
|
|
76
|
+
}
|
|
77
|
+
queue = Queue.new
|
|
78
|
+
worker_index = @mutex.synchronize do
|
|
79
|
+
@pending[request[:id]] = queue
|
|
80
|
+
index = @in_flight.each_with_index.min_by { |count, _| count }.last
|
|
81
|
+
@in_flight[index] += 1
|
|
82
|
+
index
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
@workers[worker_index] << request
|
|
87
|
+
deadline_ms = config.timeout_ms + POP_SLACK_MS
|
|
88
|
+
result = queue.pop(timeout: deadline_ms / 1000.0)
|
|
89
|
+
raise ThreadError, "no result" if result.nil? # Queue#pop timeout
|
|
90
|
+
record(result)
|
|
91
|
+
result
|
|
92
|
+
rescue ThreadError, Ractor::ClosedError
|
|
93
|
+
# Worker died, stalled beyond the deadline, or was shut down mid-flight:
|
|
94
|
+
# report instead of hang or leak the error.
|
|
95
|
+
@mutex.synchronize { @metrics[:timeouts] += 1 }
|
|
96
|
+
Result.worker_unavailable(worker_index)
|
|
97
|
+
ensure
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@in_flight[worker_index] -= 1 if @in_flight[worker_index] > 0
|
|
100
|
+
@pending.delete(request[:id])
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Snapshot of the counters: {size:, evals:, total_ms:, avg_ms:, timeouts:}.
|
|
106
|
+
def metrics
|
|
107
|
+
@mutex.synchronize do
|
|
108
|
+
evals = @metrics[:evals]
|
|
109
|
+
{ size: @size, evals: evals, total_ms: @metrics[:total_ms].round(2),
|
|
110
|
+
avg_ms: evals.zero? ? 0.0 : (@metrics[:total_ms] / evals).round(2),
|
|
111
|
+
timeouts: @metrics[:timeouts] }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def closed?
|
|
116
|
+
@mutex.synchronize { @closed }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Asks all workers to stop. Evals already in flight finish; new evals
|
|
120
|
+
# raise PoolClosed.
|
|
121
|
+
def shutdown
|
|
122
|
+
@mutex.synchronize { @closed = true }
|
|
123
|
+
@workers.each { |worker| worker << STOP }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def resolve_config(profile, options)
|
|
129
|
+
case profile
|
|
130
|
+
when nil then Configuration.build(**options)
|
|
131
|
+
when Symbol, String then Registry.resolve(profile).with(**options)
|
|
132
|
+
when Configuration then profile.with(**options)
|
|
133
|
+
else raise ArgumentError, "profile must be a registered name or a Configuration, got #{profile.class}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_worker(engine, module_, worker_index)
|
|
138
|
+
Ractor.new(engine, module_, worker_index, name: "security_box-worker-#{worker_index}") do |eng, mod, index|
|
|
139
|
+
linker = Wasmtime::Linker.new(eng).tap { |l| Wasmtime::WASI::P1.add_to_linker_sync(l) }
|
|
140
|
+
loop do
|
|
141
|
+
request = Ractor.receive
|
|
142
|
+
break if request == :security_box_stop
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
result = SecurityBox::EvalRun.run(
|
|
146
|
+
engine: eng, module_: mod, linker: linker,
|
|
147
|
+
config: request[:config], code: request[:code], token: request[:token]
|
|
148
|
+
)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
result = SecurityBox::Result.new(
|
|
151
|
+
status: :sandbox_error,
|
|
152
|
+
stderr: "security_box: worker #{index} crashed: #{e.class}: #{e.message}\n"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
Ractor.main << { id: request[:id], worker: index, result: result }
|
|
156
|
+
end
|
|
157
|
+
Ractor.main << { type: :stopped, worker: index }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def start_collector_thread
|
|
162
|
+
@collector_thread = Thread.new do
|
|
163
|
+
stopped = 0
|
|
164
|
+
loop do
|
|
165
|
+
message = Ractor.receive
|
|
166
|
+
if message[:type] == :stopped
|
|
167
|
+
stopped += 1
|
|
168
|
+
break if stopped >= @size
|
|
169
|
+
else
|
|
170
|
+
route(message)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
@collector_thread.name = "security_box-collector"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def route(message)
|
|
178
|
+
@mutex.synchronize do
|
|
179
|
+
@in_flight[message[:worker]] -= 1 if @in_flight[message[:worker]] > 0
|
|
180
|
+
queue = @pending.delete(message[:id])
|
|
181
|
+
queue << message[:result] if queue
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def record(result)
|
|
186
|
+
@mutex.synchronize do
|
|
187
|
+
@metrics[:evals] += 1
|
|
188
|
+
@metrics[:total_ms] += result.duration_ms.to_f
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def next_id
|
|
193
|
+
@mutex.synchronize do
|
|
194
|
+
@next_id += 1
|
|
195
|
+
@next_id
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -21,7 +21,12 @@ module SecurityBox
|
|
|
21
21
|
|
|
22
22
|
builder = Configuration::Builder.new
|
|
23
23
|
block&.call(builder)
|
|
24
|
-
|
|
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
|
|
data/lib/security_box/result.rb
CHANGED
|
@@ -29,6 +29,13 @@ module SecurityBox
|
|
|
29
29
|
@status == :ok
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Factory for the "the worker never answered" sandbox failure (dead or
|
|
33
|
+
# stalled Ractor worker, or the pop deadline elapsed).
|
|
34
|
+
def self.worker_unavailable(worker_index)
|
|
35
|
+
new(status: :sandbox_error,
|
|
36
|
+
stderr: "security_box: RactorPool worker #{worker_index} did not answer\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
32
39
|
def to_h
|
|
33
40
|
{
|
|
34
41
|
status: @status,
|
data/lib/security_box/runtime.rb
CHANGED
|
@@ -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)
|