hotcell-server 0.0.0 → 0.2.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/MIT-LICENSE +20 -0
- data/README.md +3 -0
- data/exe/hotcell +11 -0
- data/exe/hotcell-health +38 -0
- data/lib/hot_cell/configuration.rb +124 -0
- data/lib/hot_cell/control.rb +62 -0
- data/lib/hot_cell/counters.rb +50 -0
- data/lib/hot_cell/limits.rb +142 -0
- data/lib/hot_cell/log.rb +120 -0
- data/lib/hot_cell/operation.rb +205 -0
- data/lib/hot_cell/registry.rb +51 -0
- data/lib/hot_cell/server/errors.rb +16 -0
- data/lib/hot_cell/server/version.rb +7 -0
- data/lib/hot_cell/server.rb +45 -0
- data/lib/hot_cell/slot.rb +171 -0
- data/lib/hot_cell/supervisor.rb +884 -0
- data/lib/hot_cell/test_cell.rb +156 -0
- data/lib/hot_cell/test_operations.rb +465 -0
- data/lib/hot_cell/timing.rb +47 -0
- data/lib/hot_cell/worker.rb +324 -0
- data/lib/hotcell-server.rb +6 -0
- metadata +55 -9
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hot_cell/server"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module HotCell
|
|
8
|
+
# A real cell in a real process, for anybody's test suite.
|
|
9
|
+
#
|
|
10
|
+
# This ships here rather than being written again by each consumer, because every consumer otherwise
|
|
11
|
+
# writes its own stub cell and they drift. It is not a stub: it forks, it passes descriptors, it applies
|
|
12
|
+
# limits, and it reaps — none of which an in-process double would exercise, and all of which is where the
|
|
13
|
+
# interesting failures are.
|
|
14
|
+
#
|
|
15
|
+
# The operations it carries are whatever the calling process has defined, because a worker inherits the
|
|
16
|
+
# registry through the fork, exactly as a real cell does at boot.
|
|
17
|
+
#
|
|
18
|
+
# HotCell::TestCell.boot(concurrency: 2) do |cell|
|
|
19
|
+
# HotCell.root = File.dirname(cell.directory)
|
|
20
|
+
# ...
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Some operations cannot be loaded in a test process at all, and libvips is the reason this matters: its
|
|
24
|
+
# thread pool does not survive a fork, so a suite that required it before booting a cell would make every
|
|
25
|
+
# worker deadlock. Pass `operations:` a callable and it runs inside the cell's own process, before it boots —
|
|
26
|
+
# which is the only place such a library may be loaded.
|
|
27
|
+
#
|
|
28
|
+
# HotCell::TestCell.boot(operations: -> { require "active_storage/hot_cell/server" })
|
|
29
|
+
class TestCell
|
|
30
|
+
READY = "up"
|
|
31
|
+
|
|
32
|
+
attr_reader :name, :directory, :workspace, :log_path
|
|
33
|
+
|
|
34
|
+
def self.boot(**options)
|
|
35
|
+
new(**options).start.tap do |cell|
|
|
36
|
+
return cell unless block_given?
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
yield cell
|
|
40
|
+
ensure
|
|
41
|
+
cell.stop
|
|
42
|
+
cell.cleanup
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Anything in `supervisor:` goes to Supervisor.new; everything else is the cell's own limits.
|
|
48
|
+
def initialize(name: "test", supervisor: {}, operations: nil, **options)
|
|
49
|
+
@name = name
|
|
50
|
+
@supervisor_options = supervisor
|
|
51
|
+
@operations = operations
|
|
52
|
+
@options = options
|
|
53
|
+
@root = Dir.mktmpdir "hotcell-test"
|
|
54
|
+
@directory = File.join(@root, name)
|
|
55
|
+
@workspace = File.join(@root, "workspace")
|
|
56
|
+
@log_path = File.join(@root, "cell.log")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The parent of the cell's own directory, which is what a client registers as HotCell.root.
|
|
60
|
+
def socket_root
|
|
61
|
+
@root
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Writes a byte down a pipe once it is listening, so nothing here waits on a sleep.
|
|
65
|
+
def start
|
|
66
|
+
reader, writer = IO.pipe
|
|
67
|
+
|
|
68
|
+
@pid = fork do
|
|
69
|
+
reader.close
|
|
70
|
+
|
|
71
|
+
# The cell must not hold the test runner's stdout. A cell that outlives its test would otherwise keep
|
|
72
|
+
# the runner's pipe open, and a failing assertion becomes a hang rather than a failure.
|
|
73
|
+
$stdout.reopen log_path, "a"
|
|
74
|
+
$stderr.reopen log_path, "a"
|
|
75
|
+
|
|
76
|
+
HotCell.limits(**@options) unless @options.empty?
|
|
77
|
+
|
|
78
|
+
supervisor = Supervisor.new(directory: directory, workspace: workspace,
|
|
79
|
+
log: Log.new(File.open(log_path, "w")), **@supervisor_options)
|
|
80
|
+
begin
|
|
81
|
+
@operations&.call
|
|
82
|
+
supervisor.boot
|
|
83
|
+
writer.write READY
|
|
84
|
+
writer.close
|
|
85
|
+
supervisor.run
|
|
86
|
+
# StandardError is enough. What must not happen is an exception escaping this block, because Ruby would
|
|
87
|
+
# then run at_exit in the child — including minitest's autorun, which starts the whole suite over inside
|
|
88
|
+
# a forked cell. The `ensure exit!` below is what prevents that, for anything raised. This rescue only
|
|
89
|
+
# writes the diagnostic the parent reads.
|
|
90
|
+
rescue StandardError => error
|
|
91
|
+
File.write log_path, "#{error.class}: #{error.message}\n" \
|
|
92
|
+
"#{error.backtrace&.first(10)&.join("\n")}\n", mode: "a"
|
|
93
|
+
ensure
|
|
94
|
+
exit! 0
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
writer.close
|
|
99
|
+
ready = reader.read(READY.bytesize)
|
|
100
|
+
reader.close
|
|
101
|
+
raise "the cell did not boot: #{log}" unless ready == READY
|
|
102
|
+
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Terminates the cell and leaves its files, so a test can read the log once everything that was going to
|
|
107
|
+
# write to it has exited.
|
|
108
|
+
def stop
|
|
109
|
+
return if @pid.nil?
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
Process.kill :TERM, @pid
|
|
113
|
+
wait_for_exit
|
|
114
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@pid = nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def cleanup
|
|
122
|
+
FileUtils.remove_entry @root if Dir.exist?(@root)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def log
|
|
126
|
+
File.exist?(log_path) ? File.read(log_path) : ""
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def log_events(event)
|
|
130
|
+
log.lines.filter_map do |line|
|
|
131
|
+
parsed = begin
|
|
132
|
+
JSON.parse line, symbolize_names: true
|
|
133
|
+
rescue JSON::ParserError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
parsed if parsed.is_a?(Hash) && parsed[:event].is_a?(Hash) && parsed[:event][:action] == event
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
def wait_for_exit(within: 5)
|
|
143
|
+
deadline = Clock.now + within
|
|
144
|
+
|
|
145
|
+
loop do
|
|
146
|
+
return if Process.wait(@pid, Process::WNOHANG)
|
|
147
|
+
break if Clock.now > deadline
|
|
148
|
+
|
|
149
|
+
sleep 0.01
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
Process.kill :KILL, @pid
|
|
153
|
+
Process.wait @pid
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
# Fixture operations, so the whole surface can be exercised in milliseconds, with no tool installed and no
|
|
7
|
+
# container running.
|
|
8
|
+
#
|
|
9
|
+
# They ship rather than sitting in this gem's own test directory, for the reason hot_cell/test_cell.rb does:
|
|
10
|
+
# hotcell-client boots a real cell and needs an inventory to point it at. It had written its own, and five of
|
|
11
|
+
# those answered to routing names these already claim — so `test.uppercase` meant one thing when the client
|
|
12
|
+
# suite proved it and another when this one did.
|
|
13
|
+
#
|
|
14
|
+
# Namespaced because this is a shipped file and `Fixtures` at the top level belongs to the application. Both
|
|
15
|
+
# suites alias it in their own helper.
|
|
16
|
+
module HotCell
|
|
17
|
+
module Fixtures
|
|
18
|
+
class Uppercase < HotCell::Operation
|
|
19
|
+
operation "test.uppercase"
|
|
20
|
+
|
|
21
|
+
def perform(inputs, outputs)
|
|
22
|
+
source, = inputs
|
|
23
|
+
destination, = outputs
|
|
24
|
+
File.binwrite destination.path, File.binread(source.path).upcase
|
|
25
|
+
|
|
26
|
+
{ bytes: File.size(destination.path) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Two inputs, one output, so the request shape with several inputs is covered.
|
|
31
|
+
class Concatenate < HotCell::Operation
|
|
32
|
+
operation "test.concatenate"
|
|
33
|
+
|
|
34
|
+
def perform(inputs, outputs, separator: "")
|
|
35
|
+
destination, = outputs
|
|
36
|
+
File.binwrite destination.path, inputs.map { |input| File.binread(input.path) }.join(separator.to_s)
|
|
37
|
+
|
|
38
|
+
{ inputs: inputs.size }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Analysis: metadata out and no bytes, which is the shape with no outputs at all.
|
|
43
|
+
class Measure < HotCell::Operation
|
|
44
|
+
operation "test.measure"
|
|
45
|
+
|
|
46
|
+
def perform(inputs, _outputs, asked_for: nil)
|
|
47
|
+
source, = inputs
|
|
48
|
+
|
|
49
|
+
{ bytes: File.size(source.path), digest: Digest::SHA256.file(source.path).hexdigest[0, 8],
|
|
50
|
+
asked_for: asked_for }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# No inputs and no outputs, like rendering an initials avatar from nothing but a payload.
|
|
55
|
+
class Echo < HotCell::Operation
|
|
56
|
+
operation "test.echo"
|
|
57
|
+
|
|
58
|
+
def perform(_inputs, _outputs, **payload)
|
|
59
|
+
{ echoed: payload }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class WhoAmI < HotCell::Operation
|
|
64
|
+
operation "test.whoami"
|
|
65
|
+
|
|
66
|
+
def perform(_inputs, _outputs)
|
|
67
|
+
{ pid: Process.pid, home: ENV["HOME"] }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Leaves its $HOME in a state the worker cannot remove, by taking write permission off a subdirectory
|
|
72
|
+
# that still has a file in it. A tool running as this user can do the same to a sibling's directory,
|
|
73
|
+
# which is why a removal that fails has to be reported rather than swallowed.
|
|
74
|
+
class UnremovableHome < HotCell::Operation
|
|
75
|
+
operation "test.unremovable_home"
|
|
76
|
+
|
|
77
|
+
def perform(_inputs, _outputs)
|
|
78
|
+
blocked = File.join(ENV["HOME"].to_s, "blocked")
|
|
79
|
+
FileUtils.mkdir_p blocked
|
|
80
|
+
File.write File.join(blocked, "file"), "x"
|
|
81
|
+
File.chmod 0o500, blocked
|
|
82
|
+
|
|
83
|
+
{ blocked: blocked }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Leaves a file in $HOME and says whether an earlier request already left one. A tool reads its
|
|
88
|
+
# configuration from $HOME and a configuration file is executable — ImageMagick runs the command lines
|
|
89
|
+
# in delegates.xml and applies the rights in policy.xml — so a home that outlives its request lets one
|
|
90
|
+
# compromised conversion reconfigure every later one on that slot. See adr/0003.
|
|
91
|
+
class HomeMarker < HotCell::Operation
|
|
92
|
+
operation "test.home_marker"
|
|
93
|
+
|
|
94
|
+
def perform(_inputs, _outputs)
|
|
95
|
+
found = File.exist?(marker)
|
|
96
|
+
File.write marker, "planted"
|
|
97
|
+
|
|
98
|
+
{ found: found, home: ENV["HOME"] }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
def marker
|
|
103
|
+
File.join ENV["HOME"].to_s, "planted-by-an-earlier-request"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# An operation that reads what a caller gave it without a copy onto scratch: it never asks for a path,
|
|
108
|
+
# which is what an operation reading only a container header wants rather than a multi-gigabyte copy.
|
|
109
|
+
class Reverse < HotCell::Operation
|
|
110
|
+
operation "test.reverse"
|
|
111
|
+
|
|
112
|
+
def perform(inputs, outputs)
|
|
113
|
+
outputs.first.to_io.write inputs.first.to_io.read.reverse
|
|
114
|
+
|
|
115
|
+
{ copied: inputs.first.staged? }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class Broken < HotCell::Operation
|
|
120
|
+
operation "test.broken"
|
|
121
|
+
|
|
122
|
+
def perform(_inputs, _outputs)
|
|
123
|
+
raise "the operation itself is broken"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class Undecodable < HotCell::Operation
|
|
128
|
+
operation "test.undecodable"
|
|
129
|
+
|
|
130
|
+
def perform(_inputs, _outputs)
|
|
131
|
+
raise HotCell::UnreadableInput, "not an image at all"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A library exception an operation declares as meaning "the input could not be decoded", the way the
|
|
136
|
+
# Active Storage operations will declare Vips::Error.
|
|
137
|
+
class LibraryError < StandardError; end
|
|
138
|
+
|
|
139
|
+
class DeclaredUnreadable < HotCell::Operation
|
|
140
|
+
operation "test.declared_unreadable"
|
|
141
|
+
unreadable LibraryError
|
|
142
|
+
|
|
143
|
+
def perform(_inputs, _outputs)
|
|
144
|
+
raise LibraryError, "the library says no"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
class Hungry < HotCell::Operation
|
|
149
|
+
operation "test.hungry"
|
|
150
|
+
|
|
151
|
+
def perform(_inputs, _outputs)
|
|
152
|
+
raise HotCell::MemoryExhausted, "out of memory -- size == 732MB"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A tool spawn that fails because the host is out of memory. fork raises Errno::ENOMEM under host
|
|
157
|
+
# pressure, before the tool has read a byte of the input, so this is the cell having a bad moment
|
|
158
|
+
# rather than a decompression bomb — and it must not be recorded as a permanent memory verdict.
|
|
159
|
+
class StarvedSpawn < HotCell::Operation
|
|
160
|
+
operation "test.starved_spawn"
|
|
161
|
+
|
|
162
|
+
def perform(_inputs, _outputs)
|
|
163
|
+
raise Errno::ENOMEM, "Cannot allocate memory - fork(2)"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
class SignalsSibling < HotCell::Operation
|
|
168
|
+
operation "test.signals_sibling"
|
|
169
|
+
|
|
170
|
+
# Workers share a uid and a pid namespace, so one finds another by looking for a process the
|
|
171
|
+
# supervisor also fathered. This is the reproducer for the forged verdict: nothing here touches the
|
|
172
|
+
# victim's input, and the victim is holding an unrelated one.
|
|
173
|
+
def perform(_inputs, _outputs, signal:)
|
|
174
|
+
sibling = siblings.first
|
|
175
|
+
Process.kill signal, sibling if sibling
|
|
176
|
+
|
|
177
|
+
{ signalled: sibling }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
# Two ways to ask the same question, because the suite runs on macOS as well and only one of them
|
|
182
|
+
# has /proc. An attacker inside a cell has whichever the image gives it; the point of the reproducer
|
|
183
|
+
# is that the answer is obtainable at all.
|
|
184
|
+
def siblings
|
|
185
|
+
pids = Dir.exist?("/proc") ? procfs_children : ps_children
|
|
186
|
+
|
|
187
|
+
pids.reject { |pid| pid == Process.pid }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def procfs_children
|
|
191
|
+
Dir.glob("/proc/[0-9]*").filter_map do |path|
|
|
192
|
+
status = File.read(File.join(path, "status"))
|
|
193
|
+
File.basename(path).to_i if status[/^PPid:\s+(\d+)/, 1].to_i == Process.ppid
|
|
194
|
+
rescue SystemCallError
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def ps_children
|
|
200
|
+
`ps -A -o pid=,ppid=`.lines.filter_map do |line|
|
|
201
|
+
pid, ppid = line.split.map(&:to_i)
|
|
202
|
+
pid if ppid == Process.ppid
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Spawns a process and does not wait for it, the way a worker that crashed mid-request would leave a
|
|
208
|
+
# tool behind. The spawned process inherits the worker's process group, so the supervisor's group sweep
|
|
209
|
+
# at reap is what must kill it — nothing else is watching it, and it has no deadline. Returns the pid so
|
|
210
|
+
# a test can watch for it.
|
|
211
|
+
class Orphaner < HotCell::Operation
|
|
212
|
+
operation "test.orphaner"
|
|
213
|
+
|
|
214
|
+
def perform(_inputs, _outputs)
|
|
215
|
+
{ spawned: Process.spawn("sleep", "300") }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
class BadResult < HotCell::Operation
|
|
220
|
+
operation "test.bad_result"
|
|
221
|
+
|
|
222
|
+
def perform(_inputs, _outputs)
|
|
223
|
+
"a String is not a result"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
class UnserializableResult < HotCell::Operation
|
|
228
|
+
operation "test.unserializable_result"
|
|
229
|
+
|
|
230
|
+
def perform(_inputs, _outputs)
|
|
231
|
+
{ format: :png }
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Returns without writing anything, which is how a full tmpfs arrives too.
|
|
236
|
+
class Silent < HotCell::Operation
|
|
237
|
+
operation "test.silent"
|
|
238
|
+
|
|
239
|
+
def perform(_inputs, _outputs)
|
|
240
|
+
{}
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
class Overflowing < HotCell::Operation
|
|
245
|
+
operation "test.overflowing"
|
|
246
|
+
|
|
247
|
+
def perform(_inputs, outputs, megabytes:)
|
|
248
|
+
File.open(outputs.first.path, "wb") do |file|
|
|
249
|
+
megabytes.times { file.write "x" * (1024 * 1024) }
|
|
250
|
+
file.flush
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
{}
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Pins the worker where Ruby cannot interrupt it.
|
|
258
|
+
#
|
|
259
|
+
# A deadline test built on sleep passes against a self-enforcing implementation that could never work in
|
|
260
|
+
# production, because Timeout raises at an interrupt checkpoint and a thread inside a C extension does
|
|
261
|
+
# not reach one until it returns. libvips is the real case. Deferring the raise reproduces that on every
|
|
262
|
+
# Ruby and every build, which a long computation cannot: Integer#** takes seven seconds without GMP and
|
|
263
|
+
# a fraction of a second with it. SIGKILL is not deferrable, so the supervisor still gets through.
|
|
264
|
+
class Uninterruptible < HotCell::Operation
|
|
265
|
+
operation "test.uninterruptible"
|
|
266
|
+
SECONDS = 60
|
|
267
|
+
|
|
268
|
+
def perform(_inputs, _outputs)
|
|
269
|
+
Thread.handle_interrupt(Exception => :never) { sleep SECONDS }
|
|
270
|
+
{}
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Starts a grandchild that outlives the worker, and reports its pid so a test can ask whether the
|
|
275
|
+
# deadline reached it. `spawn` rather than a tool, so this needs no toolchain installed.
|
|
276
|
+
class Spawns < HotCell::Operation
|
|
277
|
+
operation "test.spawns"
|
|
278
|
+
|
|
279
|
+
def perform(_inputs, _outputs)
|
|
280
|
+
pid = spawn("sleep", "60")
|
|
281
|
+
tell_and_wait pid
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
def tell_and_wait(pid)
|
|
286
|
+
File.write ENV.fetch("HOTCELL_SPAWNED_PID_PATH"), pid.to_s
|
|
287
|
+
sleep 60
|
|
288
|
+
{ pid: pid }
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Declares less than the cell allows, so the supervisor has to learn the narrower number from the worker
|
|
293
|
+
# rather than from the request it never reads.
|
|
294
|
+
class Impatient < Uninterruptible
|
|
295
|
+
operation "test.impatient"
|
|
296
|
+
limits deadline: 1
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Declares more than the cell allows, which the cell clamps. Invariant 6.
|
|
300
|
+
class Patient < Uninterruptible
|
|
301
|
+
operation "test.patient"
|
|
302
|
+
limits deadline: 300
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Hands an exec'd tool the input descriptor and has it read the bytes back through /dev/fd, so a test
|
|
306
|
+
# can prove run_tool's `pass:` exposes a descriptor to a tool without staging it. Reports whether the
|
|
307
|
+
# input was copied onto scratch, which must be false.
|
|
308
|
+
class ReadThroughFd < HotCell::Operation
|
|
309
|
+
operation "test.read_through_fd"
|
|
310
|
+
|
|
311
|
+
def perform(inputs, _outputs)
|
|
312
|
+
source, = inputs
|
|
313
|
+
result = run_tool "cat", source.fd_path, pass: [ source.to_io ]
|
|
314
|
+
|
|
315
|
+
{ content: result.out, ok: result.ok?, staged: source.staged? }
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Reports what an exec'd tool can see of its environment, and what the worker itself can see, so a
|
|
320
|
+
# test can prove the canary was really there before proving the tool never got it.
|
|
321
|
+
class Environment < HotCell::Operation
|
|
322
|
+
operation "test.environment"
|
|
323
|
+
|
|
324
|
+
def perform(_inputs, _outputs, env: {}, canary: nil)
|
|
325
|
+
result = run_tool "env", env: env
|
|
326
|
+
|
|
327
|
+
{ seen: result.out.lines.map(&:chomp).sort, worker_saw: ENV[canary.to_s],
|
|
328
|
+
ok: result.ok? }
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Prints far more than any sane capture, on the stream the payload names, then exits. For proving that
|
|
333
|
+
# a noisy tool costs a bounded amount of this worker's memory rather than all of it.
|
|
334
|
+
class Noisy < HotCell::Operation
|
|
335
|
+
operation "test.noisy"
|
|
336
|
+
|
|
337
|
+
def perform(_inputs, _outputs, stream: "out")
|
|
338
|
+
stream = stream == "err" ? "STDERR" : "STDOUT"
|
|
339
|
+
script = "40.times { #{stream}.write('x' * 1_000_000) }"
|
|
340
|
+
result = run_tool "ruby", "-e", script, capture: 1024
|
|
341
|
+
|
|
342
|
+
{ out: result.out.bytesize, err: result.err.bytesize, ok: result.ok? }
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Two operations that configure the same global, so a worker serving A, B, A can be asked what the
|
|
347
|
+
# global says at the moment each one ran.
|
|
348
|
+
CONFIGURED = []
|
|
349
|
+
|
|
350
|
+
class ConfiguresGlobal < HotCell::Operation
|
|
351
|
+
abstract_operation
|
|
352
|
+
|
|
353
|
+
def perform(_inputs, _outputs)
|
|
354
|
+
{ configured_for: CONFIGURED.last }
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
class ConfiguresAlpha < ConfiguresGlobal
|
|
359
|
+
operation "test.configures_alpha"
|
|
360
|
+
before_worker_boot { CONFIGURED << "alpha" }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
class ConfiguresBeta < ConfiguresGlobal
|
|
364
|
+
operation "test.configures_beta"
|
|
365
|
+
before_worker_boot { CONFIGURED << "beta" }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Writes the first output and leaves the second alone, which is a positive total and reads as success to
|
|
369
|
+
# anything checking the aggregate.
|
|
370
|
+
class HalfWritten < HotCell::Operation
|
|
371
|
+
operation "test.half_written"
|
|
372
|
+
|
|
373
|
+
def perform(_inputs, outputs)
|
|
374
|
+
File.binwrite outputs.first.path, "only the first"
|
|
375
|
+
|
|
376
|
+
{ wrote: 1 }
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
class Rlimits < HotCell::Operation
|
|
381
|
+
operation "test.rlimits"
|
|
382
|
+
|
|
383
|
+
def perform(_inputs, _outputs)
|
|
384
|
+
{ memory: Process.getrlimit(Process::RLIMIT_DATA), file_size: Process.getrlimit(Process::RLIMIT_FSIZE),
|
|
385
|
+
open_files: Process.getrlimit(Process::RLIMIT_NOFILE), core: Process.getrlimit(Process::RLIMIT_CORE) }
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Asks for less than the cell allows, so the soft limit narrows and the hard limit does not.
|
|
390
|
+
class Frugal < Rlimits
|
|
391
|
+
operation "test.frugal"
|
|
392
|
+
limits memory: 1024 * 1024**2, file_size: 4 * 1024 * 1024, open_files: 64
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Asks for more than any cell will allow, on every limit. Unclamped, the worker would try to set a soft limit
|
|
396
|
+
# above its own hard limit and die before it could answer.
|
|
397
|
+
class Extravagant < Rlimits
|
|
398
|
+
operation "test.extravagant"
|
|
399
|
+
limits memory: 8 * 1024**3, file_size: 512 * 1024**2, open_files: 4096, deadline: 3600
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
class Greedy < HotCell::Operation
|
|
403
|
+
operation "test.greedy"
|
|
404
|
+
|
|
405
|
+
def perform(_inputs, _outputs, megabytes:)
|
|
406
|
+
{ bytes: ("x" * (megabytes * 1024 * 1024)).bytesize }
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# A result carrying bytes a tool produced, which is where invalid UTF-8 comes from in practice.
|
|
411
|
+
class Mojibake < HotCell::Operation
|
|
412
|
+
operation "test.mojibake"
|
|
413
|
+
|
|
414
|
+
def perform(_inputs, _outputs)
|
|
415
|
+
{ filename: "caf\xFF.jpg".dup.force_encoding(Encoding::UTF_8) }
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Dies mid-request without answering and without a signal, which is what a cell fault looks like as
|
|
420
|
+
# distinct from an input fault.
|
|
421
|
+
class Vanishes < HotCell::Operation
|
|
422
|
+
operation "test.vanishes"
|
|
423
|
+
|
|
424
|
+
def perform(_inputs, _outputs)
|
|
425
|
+
exit! 3
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Reports itself idle while its request is still running, so the report and the truth disagree from
|
|
430
|
+
# that moment on. The control socket is private to the Worker, but it lives in the operation's own
|
|
431
|
+
# process, so reaching it takes one ObjectSpace walk. With `exit_after`, the worker then exits without
|
|
432
|
+
# ever reading its control socket again, so whatever the supervisor wrote there in the meantime is
|
|
433
|
+
# queued and unread when it goes.
|
|
434
|
+
class EarlyIdle < HotCell::Operation
|
|
435
|
+
operation "test.early_idle"
|
|
436
|
+
|
|
437
|
+
def perform(_inputs, _outputs, pid_path:, exit_after: nil)
|
|
438
|
+
# The one with an open control socket, not `.first`: a suite that builds a Worker in its own
|
|
439
|
+
# process leaves it on the heap for the fork to inherit, with its sockets closed by teardown.
|
|
440
|
+
worker = ObjectSpace.each_object(HotCell::Worker).find do |candidate|
|
|
441
|
+
!candidate.instance_variable_get(:@control).socket.closed?
|
|
442
|
+
end
|
|
443
|
+
worker.instance_variable_get(:@control).write_line JSON.generate(idle: true, code: "ok") << "\n"
|
|
444
|
+
File.write pid_path, Process.pid.to_s
|
|
445
|
+
|
|
446
|
+
if exit_after
|
|
447
|
+
sleep exit_after
|
|
448
|
+
exit! 0
|
|
449
|
+
else
|
|
450
|
+
sleep 300
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
class Blocking < HotCell::Operation
|
|
456
|
+
operation "test.blocking"
|
|
457
|
+
|
|
458
|
+
def perform(_inputs, _outputs, seconds:)
|
|
459
|
+
sleep seconds
|
|
460
|
+
|
|
461
|
+
{ slept: seconds, pid: Process.pid }
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HotCell
|
|
4
|
+
# One request's timing ledger, carried through the worker instead of the two loose values it replaces.
|
|
5
|
+
#
|
|
6
|
+
# It exists for the base instant rather than for tidiness. `perform_ms` means time spent performing where
|
|
7
|
+
# performing happened, and time since the request arrived where it did not — a request refused for a
|
|
8
|
+
# protocol mismatch never performed anything, so measuring it from the start is the only meaningful number.
|
|
9
|
+
# That distinction used to live in the argument every caller passed: `refuse`'s `since` was `started` on
|
|
10
|
+
# three paths and `began` on a fourth, and getting it wrong would have been invisible. The base moves once,
|
|
11
|
+
# here, when `performing` is called.
|
|
12
|
+
#
|
|
13
|
+
# Phases accumulate as they complete, so a refusal reports the ones that finished before the failure. An
|
|
14
|
+
# `unreadable` verdict that arrives with no `operation_ms` says exactly where it got to.
|
|
15
|
+
class Timing
|
|
16
|
+
attr_reader :queued_ms, :started
|
|
17
|
+
|
|
18
|
+
def initialize(queued_ms)
|
|
19
|
+
@queued_ms = queued_ms
|
|
20
|
+
@started = Clock.now
|
|
21
|
+
@base = @started
|
|
22
|
+
@phases = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Performing starts now, so this is what perform_ms measures from.
|
|
26
|
+
def performing
|
|
27
|
+
@base = Clock.now
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def measure(phase)
|
|
31
|
+
at = Clock.now
|
|
32
|
+
result = yield
|
|
33
|
+
@phases[phase] = Clock.ms_since(at)
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
{ queued_ms: queued_ms, **@phases, perform_ms: Clock.ms_since(@base) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The whole request as the cell saw it, including reading the message. For the log line rather
|
|
42
|
+
# than for the caller, who is told what performing cost.
|
|
43
|
+
def elapsed_ms
|
|
44
|
+
Clock.ms_since started
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|