hotcell-server 0.0.0 → 0.1.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,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,425 @@
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
+ # Spawns a process and does not wait for it, the way a worker that crashed mid-request would leave a
168
+ # tool behind. The spawned process inherits the worker's process group, so the supervisor's group sweep
169
+ # at reap is what must kill it — nothing else is watching it, and it has no deadline. Returns the pid so
170
+ # a test can watch for it.
171
+ class Orphaner < HotCell::Operation
172
+ operation "test.orphaner"
173
+
174
+ def perform(_inputs, _outputs)
175
+ { spawned: Process.spawn("sleep", "300") }
176
+ end
177
+ end
178
+
179
+ class BadResult < HotCell::Operation
180
+ operation "test.bad_result"
181
+
182
+ def perform(_inputs, _outputs)
183
+ "a String is not a result"
184
+ end
185
+ end
186
+
187
+ class UnserializableResult < HotCell::Operation
188
+ operation "test.unserializable_result"
189
+
190
+ def perform(_inputs, _outputs)
191
+ { format: :png }
192
+ end
193
+ end
194
+
195
+ # Returns without writing anything, which is how a full tmpfs arrives too.
196
+ class Silent < HotCell::Operation
197
+ operation "test.silent"
198
+
199
+ def perform(_inputs, _outputs)
200
+ {}
201
+ end
202
+ end
203
+
204
+ class Overflowing < HotCell::Operation
205
+ operation "test.overflowing"
206
+
207
+ def perform(_inputs, outputs, megabytes:)
208
+ File.open(outputs.first.path, "wb") do |file|
209
+ megabytes.times { file.write "x" * (1024 * 1024) }
210
+ file.flush
211
+ end
212
+
213
+ {}
214
+ end
215
+ end
216
+
217
+ # Pins the worker where Ruby cannot interrupt it.
218
+ #
219
+ # A deadline test built on sleep passes against a self-enforcing implementation that could never work in
220
+ # production, because Timeout raises at an interrupt checkpoint and a thread inside a C extension does
221
+ # not reach one until it returns. libvips is the real case. Deferring the raise reproduces that on every
222
+ # Ruby and every build, which a long computation cannot: Integer#** takes seven seconds without GMP and
223
+ # a fraction of a second with it. SIGKILL is not deferrable, so the supervisor still gets through.
224
+ class Uninterruptible < HotCell::Operation
225
+ operation "test.uninterruptible"
226
+ SECONDS = 60
227
+
228
+ def perform(_inputs, _outputs)
229
+ Thread.handle_interrupt(Exception => :never) { sleep SECONDS }
230
+ {}
231
+ end
232
+ end
233
+
234
+ # Starts a grandchild that outlives the worker, and reports its pid so a test can ask whether the
235
+ # deadline reached it. `spawn` rather than a tool, so this needs no toolchain installed.
236
+ class Spawns < HotCell::Operation
237
+ operation "test.spawns"
238
+
239
+ def perform(_inputs, _outputs)
240
+ pid = spawn("sleep", "60")
241
+ tell_and_wait pid
242
+ end
243
+
244
+ private
245
+ def tell_and_wait(pid)
246
+ File.write ENV.fetch("HOTCELL_SPAWNED_PID_PATH"), pid.to_s
247
+ sleep 60
248
+ { pid: pid }
249
+ end
250
+ end
251
+
252
+ # Declares less than the cell allows, so the supervisor has to learn the narrower number from the worker
253
+ # rather than from the request it never reads.
254
+ class Impatient < Uninterruptible
255
+ operation "test.impatient"
256
+ limits deadline: 1
257
+ end
258
+
259
+ # Declares more than the cell allows, which the cell clamps. Invariant 6.
260
+ class Patient < Uninterruptible
261
+ operation "test.patient"
262
+ limits deadline: 300
263
+ end
264
+
265
+ # Hands an exec'd tool the input descriptor and has it read the bytes back through /dev/fd, so a test
266
+ # can prove run_tool's `pass:` exposes a descriptor to a tool without staging it. Reports whether the
267
+ # input was copied onto scratch, which must be false.
268
+ class ReadThroughFd < HotCell::Operation
269
+ operation "test.read_through_fd"
270
+
271
+ def perform(inputs, _outputs)
272
+ source, = inputs
273
+ result = run_tool "cat", source.fd_path, pass: [ source.to_io ]
274
+
275
+ { content: result.out, ok: result.ok?, staged: source.staged? }
276
+ end
277
+ end
278
+
279
+ # Reports what an exec'd tool can see of its environment, and what the worker itself can see, so a
280
+ # test can prove the canary was really there before proving the tool never got it.
281
+ class Environment < HotCell::Operation
282
+ operation "test.environment"
283
+
284
+ def perform(_inputs, _outputs, env: {}, canary: nil)
285
+ result = run_tool "env", env: env
286
+
287
+ { seen: result.out.lines.map(&:chomp).sort, worker_saw: ENV[canary.to_s],
288
+ ok: result.ok? }
289
+ end
290
+ end
291
+
292
+ # Prints far more than any sane capture, on the stream the payload names, then exits. For proving that
293
+ # a noisy tool costs a bounded amount of this worker's memory rather than all of it.
294
+ class Noisy < HotCell::Operation
295
+ operation "test.noisy"
296
+
297
+ def perform(_inputs, _outputs, stream: "out")
298
+ stream = stream == "err" ? "STDERR" : "STDOUT"
299
+ script = "40.times { #{stream}.write('x' * 1_000_000) }"
300
+ result = run_tool "ruby", "-e", script, capture: 1024
301
+
302
+ { out: result.out.bytesize, err: result.err.bytesize, ok: result.ok? }
303
+ end
304
+ end
305
+
306
+ # Two operations that configure the same global, so a worker serving A, B, A can be asked what the
307
+ # global says at the moment each one ran.
308
+ CONFIGURED = []
309
+
310
+ class ConfiguresGlobal < HotCell::Operation
311
+ abstract_operation
312
+
313
+ def perform(_inputs, _outputs)
314
+ { configured_for: CONFIGURED.last }
315
+ end
316
+ end
317
+
318
+ class ConfiguresAlpha < ConfiguresGlobal
319
+ operation "test.configures_alpha"
320
+ before_worker_boot { CONFIGURED << "alpha" }
321
+ end
322
+
323
+ class ConfiguresBeta < ConfiguresGlobal
324
+ operation "test.configures_beta"
325
+ before_worker_boot { CONFIGURED << "beta" }
326
+ end
327
+
328
+ # Writes the first output and leaves the second alone, which is a positive total and reads as success to
329
+ # anything checking the aggregate.
330
+ class HalfWritten < HotCell::Operation
331
+ operation "test.half_written"
332
+
333
+ def perform(_inputs, outputs)
334
+ File.binwrite outputs.first.path, "only the first"
335
+
336
+ { wrote: 1 }
337
+ end
338
+ end
339
+
340
+ class Rlimits < HotCell::Operation
341
+ operation "test.rlimits"
342
+
343
+ def perform(_inputs, _outputs)
344
+ { memory: Process.getrlimit(Process::RLIMIT_DATA), file_size: Process.getrlimit(Process::RLIMIT_FSIZE),
345
+ open_files: Process.getrlimit(Process::RLIMIT_NOFILE), core: Process.getrlimit(Process::RLIMIT_CORE) }
346
+ end
347
+ end
348
+
349
+ # Asks for less than the cell allows, so the soft limit narrows and the hard limit does not.
350
+ class Frugal < Rlimits
351
+ operation "test.frugal"
352
+ limits memory: 1024 * 1024**2, file_size: 4 * 1024 * 1024, open_files: 64
353
+ end
354
+
355
+ # Asks for more than any cell will allow, on every limit. Unclamped, the worker would try to set a soft limit
356
+ # above its own hard limit and die before it could answer.
357
+ class Extravagant < Rlimits
358
+ operation "test.extravagant"
359
+ limits memory: 8 * 1024**3, file_size: 512 * 1024**2, open_files: 4096, deadline: 3600
360
+ end
361
+
362
+ class Greedy < HotCell::Operation
363
+ operation "test.greedy"
364
+
365
+ def perform(_inputs, _outputs, megabytes:)
366
+ { bytes: ("x" * (megabytes * 1024 * 1024)).bytesize }
367
+ end
368
+ end
369
+
370
+ # A result carrying bytes a tool produced, which is where invalid UTF-8 comes from in practice.
371
+ class Mojibake < HotCell::Operation
372
+ operation "test.mojibake"
373
+
374
+ def perform(_inputs, _outputs)
375
+ { filename: "caf\xFF.jpg".dup.force_encoding(Encoding::UTF_8) }
376
+ end
377
+ end
378
+
379
+ # Dies mid-request without answering and without a signal, which is what a cell fault looks like as
380
+ # distinct from an input fault.
381
+ class Vanishes < HotCell::Operation
382
+ operation "test.vanishes"
383
+
384
+ def perform(_inputs, _outputs)
385
+ exit! 3
386
+ end
387
+ end
388
+
389
+ # Reports itself idle while its request is still running, so the report and the truth disagree from
390
+ # that moment on. The control socket is private to the Worker, but it lives in the operation's own
391
+ # process, so reaching it takes one ObjectSpace walk. With `exit_after`, the worker then exits without
392
+ # ever reading its control socket again, so whatever the supervisor wrote there in the meantime is
393
+ # queued and unread when it goes.
394
+ class EarlyIdle < HotCell::Operation
395
+ operation "test.early_idle"
396
+
397
+ def perform(_inputs, _outputs, pid_path:, exit_after: nil)
398
+ # The one with an open control socket, not `.first`: a suite that builds a Worker in its own
399
+ # process leaves it on the heap for the fork to inherit, with its sockets closed by teardown.
400
+ worker = ObjectSpace.each_object(HotCell::Worker).find do |candidate|
401
+ !candidate.instance_variable_get(:@control).socket.closed?
402
+ end
403
+ worker.instance_variable_get(:@control).write_line JSON.generate(idle: true, code: "ok") << "\n"
404
+ File.write pid_path, Process.pid.to_s
405
+
406
+ if exit_after
407
+ sleep exit_after
408
+ exit! 0
409
+ else
410
+ sleep 300
411
+ end
412
+ end
413
+ end
414
+
415
+ class Blocking < HotCell::Operation
416
+ operation "test.blocking"
417
+
418
+ def perform(_inputs, _outputs, seconds:)
419
+ sleep seconds
420
+
421
+ { slept: seconds, pid: Process.pid }
422
+ end
423
+ end
424
+ end
425
+ 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