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.
@@ -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