hotcell-server 0.2.0 → 0.3.1
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/lib/hot_cell/control.rb +1 -2
- data/lib/hot_cell/log.rb +5 -3
- data/lib/hot_cell/operation.rb +7 -2
- data/lib/hot_cell/server/version.rb +1 -1
- data/lib/hot_cell/supervisor.rb +144 -14
- data/lib/hot_cell/test_operations.rb +73 -0
- data/lib/hot_cell/worker.rb +25 -5
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b2d1b6a8296d8833188c1414bfeb70d7031c8601d520361b10110e832461992
|
|
4
|
+
data.tar.gz: ec7983987f989d78eeeefee5b3fa8b5c5a860d3ae1022ce414172ea553d64938
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 112abfea26523d9592dd208455e70a824937c910f98d910990b2359e0530f8f9fd01348a57b4b067a80ee9a77cf64d36aaf7e4bf704c9cf9c88d99eb324e14f1
|
|
7
|
+
data.tar.gz: 99fbe2141dee8d99e385b3fe825476366bb56988f7ca508a8ff961d302052b3ab656b07b28cce2513e45012e8a8903aac916f62e31931871fb4a55d4dceb2314
|
data/lib/hot_cell/control.rb
CHANGED
|
@@ -39,8 +39,7 @@ module HotCell
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
# Static, and called once per registered cell at app boot. It is the cheapest way to catch a client
|
|
42
|
-
#
|
|
43
|
-
# the first real request, and to catch a client whose own timeout is below what this cell may take.
|
|
42
|
+
# whose own timeout is below what this cell may take, and it is what `bin/hotcell describe` reads.
|
|
44
43
|
def describe
|
|
45
44
|
{ v: PROTOCOL_VERSION, operations: Registry.names, groups: groups, **@configuration.to_h }
|
|
46
45
|
end
|
data/lib/hot_cell/log.rb
CHANGED
|
@@ -65,9 +65,11 @@ module HotCell
|
|
|
65
65
|
# deadline loop until the runtime resumed. A non-blocking write answers `:wait_writable` for the full
|
|
66
66
|
# pipe instead, and the line is dropped like any other.
|
|
67
67
|
#
|
|
68
|
-
# A short count would be a torn line, which a write at or under PIPE_BUF cannot produce.
|
|
69
|
-
#
|
|
70
|
-
# against an empty pipe
|
|
68
|
+
# A short count would be a torn line, which a write at or under PIPE_BUF cannot produce. Torn is worse
|
|
69
|
+
# than dropped: the stub has no newline, so the next line written is glued to it and a reader loses
|
|
70
|
+
# both. cell.boot's inventory can exceed it, written once against an empty pipe before the loop
|
|
71
|
+
# enforces anything, and so can worker.killed — 512 bytes of stderr become 3072 when every one is a
|
|
72
|
+
# control character, and the operation name and envelope spend most of what is left.
|
|
71
73
|
def emit(line)
|
|
72
74
|
@io.write_nonblock line, exception: false
|
|
73
75
|
rescue SystemCallError, IOError
|
data/lib/hot_cell/operation.rb
CHANGED
|
@@ -149,7 +149,8 @@ module HotCell
|
|
|
149
149
|
# cost gigabytes of this worker's address space, and took RLIMIT_DATA with it — arriving as a `memory`
|
|
150
150
|
# verdict, which is permanent, for a document whose only crime was being noisy.
|
|
151
151
|
# `pass` hands the tool a set of the worker's own descriptors — an input to read, an output to write —
|
|
152
|
-
# at their existing fd numbers, so the tool reaches them
|
|
152
|
+
# at their existing fd numbers, so the tool reaches them at `Descriptor#fd_path` — `/dev/fd/N`, or the
|
|
153
|
+
# file's own path on macOS, where opening `/dev/fd/N` would share the worker's offset — and no
|
|
153
154
|
# byte is copied onto scratch to give it a filename. A fd handed to a child this way loses its
|
|
154
155
|
# close-on-exec, which is exactly the inheritance wanted, and only for these; the worker's other
|
|
155
156
|
# descriptors are untouched. Passing an fd at its own number cannot collide with the stdio pipes popen3
|
|
@@ -196,8 +197,12 @@ module HotCell
|
|
|
196
197
|
kept
|
|
197
198
|
end
|
|
198
199
|
|
|
200
|
+
# The OpenMP variables come from the cell's environment rather than being named here: the number
|
|
201
|
+
# belongs to the image, which is configured to match the container's CPU quota. Without them
|
|
202
|
+
# `unsetenv_others: true` would hand an exec'd tool the pool the image's bound was meant to take away.
|
|
199
203
|
def tool_environment(overrides)
|
|
200
|
-
{ "HOME" => ENV["HOME"], "PATH" => ENV["PATH"], "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8"
|
|
204
|
+
{ "HOME" => ENV["HOME"], "PATH" => ENV["PATH"], "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8",
|
|
205
|
+
"OMP_NUM_THREADS" => ENV["OMP_NUM_THREADS"], "OMP_THREAD_LIMIT" => ENV["OMP_THREAD_LIMIT"] }
|
|
201
206
|
.merge(overrides.transform_keys(&:to_s))
|
|
202
207
|
.compact
|
|
203
208
|
end
|
data/lib/hot_cell/supervisor.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "socket"
|
|
4
|
+
require "fcntl"
|
|
4
5
|
require "fileutils"
|
|
5
6
|
require "tmpdir"
|
|
6
7
|
|
|
@@ -13,6 +14,8 @@ module HotCell
|
|
|
13
14
|
# the trusted side on a bounded buffer — but the supervisor does not need to, and staying out of the
|
|
14
15
|
# request is what lets it dispatch a connection whose descriptors are still queued on it.
|
|
15
16
|
#
|
|
17
|
+
# `peeked_op` is the exception, on the one path where no worker will read the request.
|
|
18
|
+
#
|
|
16
19
|
# Dispatching rather than letting workers accept is what makes the rest work. The supervisor needs to own
|
|
17
20
|
# the accept anyway, for the queue, for queued_ms, and to answer `capacity`. It also means the supervisor
|
|
18
21
|
# knows when every worker started its current request, which is what the deadline needs.
|
|
@@ -21,16 +24,36 @@ module HotCell
|
|
|
21
24
|
# `busy?` and the supervisor assigning the four fields `busy?` is computed from are the same fact. Spread
|
|
22
25
|
# across the caller, a new field is one the next transition forgets to clear.
|
|
23
26
|
Child = Struct.new(:slot, :pid, :control, :connection, :dispatched_at, :deadline, :served, :killed_for,
|
|
24
|
-
:retired_at, :buffer, keyword_init: true) do
|
|
25
|
-
def self.build(slot:, pid:, control:, deadline:)
|
|
26
|
-
new slot: slot, pid: pid, control: control, deadline: deadline, served: 0, buffer: "".b
|
|
27
|
+
:op, :retired_at, :buffer, :stderr, :captured, keyword_init: true) do
|
|
28
|
+
def self.build(slot:, pid:, control:, deadline:, stderr: nil)
|
|
29
|
+
new slot: slot, pid: pid, control: control, deadline: deadline, served: 0, buffer: "".b,
|
|
30
|
+
stderr: stderr, captured: "".b
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Keeps the last MAX_MESSAGE_BYTES rather than the first, because the line that ended the request is
|
|
34
|
+
# the last one written. Trimmed on every read, so a worker that never stops printing costs no more
|
|
35
|
+
# than that.
|
|
36
|
+
def capture_stderr(chunk)
|
|
37
|
+
captured << chunk
|
|
38
|
+
excess = captured.bytesize - Failure::MAX_MESSAGE_BYTES
|
|
39
|
+
captured.slice! 0, excess if excess.positive?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A silent worker gets no field at all: `Log#document` does not compact the hotcell namespace, so a nil
|
|
43
|
+
# would put `"stderr":null` on every death a cell reports.
|
|
44
|
+
def stderr_field
|
|
45
|
+
captured.empty? ? {} : { stderr: Failure.sanitize(captured, keep: :tail) }
|
|
27
46
|
end
|
|
28
47
|
|
|
29
48
|
def dispatched(connection, deadline, at:)
|
|
49
|
+
# An earlier request's warning is not this request's death. Not a boundary, though: a descendant
|
|
50
|
+
# holding fd 2 writes whenever it likes, and a sibling can open /proc/<pid>/fd/2 and write there too.
|
|
51
|
+
captured.clear
|
|
30
52
|
self.connection = connection
|
|
31
53
|
self.dispatched_at = at
|
|
32
54
|
self.deadline = deadline
|
|
33
55
|
self.killed_for = nil
|
|
56
|
+
self.op = nil
|
|
34
57
|
self.served += 1
|
|
35
58
|
end
|
|
36
59
|
|
|
@@ -105,6 +128,12 @@ module HotCell
|
|
|
105
128
|
# far above any real scrape rate rather than as a throttle.
|
|
106
129
|
CONTROL_BACKLOG = 64
|
|
107
130
|
|
|
131
|
+
# What one pass reads off a worker's fd 2, and how many reads the reap's final drain gets. Sized to
|
|
132
|
+
# clear a full pipe rather than to bound memory — `Child#capture_stderr` does that — so reading further
|
|
133
|
+
# would only mean a fresher tail.
|
|
134
|
+
STDERR_READ_BYTES = 16 * 1024
|
|
135
|
+
STDERR_FINAL_READS = 8
|
|
136
|
+
|
|
108
137
|
attr_reader :configuration, :counters, :log, :directory, :workspace
|
|
109
138
|
|
|
110
139
|
def initialize(directory:, workspace: nil, configuration: HotCell.configuration, log: Log.new,
|
|
@@ -163,6 +192,7 @@ module HotCell
|
|
|
163
192
|
def sources
|
|
164
193
|
[ @signals ].tap do |list|
|
|
165
194
|
list.concat @children.each_value.map { |child| child.control.socket }.reject(&:closed?)
|
|
195
|
+
list.concat @children.each_value.filter_map(&:stderr).reject(&:closed?)
|
|
166
196
|
list.concat @control_pending.map { |pending| pending.connection.socket }.reject(&:closed?)
|
|
167
197
|
list.push @work, @control unless @stopping
|
|
168
198
|
end
|
|
@@ -187,8 +217,13 @@ module HotCell
|
|
|
187
217
|
when @work then accept_work
|
|
188
218
|
when @control then accept_control
|
|
189
219
|
else
|
|
190
|
-
pending = pending_control(source)
|
|
191
|
-
|
|
220
|
+
if (pending = pending_control(source))
|
|
221
|
+
read_control pending
|
|
222
|
+
elsif (child = child_writing_stderr(source))
|
|
223
|
+
drain_stderr child
|
|
224
|
+
else
|
|
225
|
+
child_reported source
|
|
226
|
+
end
|
|
192
227
|
end
|
|
193
228
|
end
|
|
194
229
|
|
|
@@ -358,7 +393,7 @@ module HotCell
|
|
|
358
393
|
true
|
|
359
394
|
rescue SystemCallError, IOError => error
|
|
360
395
|
log.write "worker.undispatchable", pid: child.pid, slot: child.slot.number,
|
|
361
|
-
error: error.class.name
|
|
396
|
+
op: peeked_op(connection), error: error.class.name
|
|
362
397
|
|
|
363
398
|
# Released rather than finished: this is the only path that hands the connection back to be answered
|
|
364
399
|
# here, so the client connection must not be closed on the way out. `retire` closes the worker's
|
|
@@ -370,6 +405,20 @@ module HotCell
|
|
|
370
405
|
false
|
|
371
406
|
end
|
|
372
407
|
|
|
408
|
+
# Safe only because the dispatch failed: no worker will serve this request. A peek and a bytes-only
|
|
409
|
+
# `recv`, never `recvmsg` — a partial `send_message` can leave a worker holding this connection, and
|
|
410
|
+
# consuming the request would take it from that worker. `recv_nonblock` rather than `MSG_DONTWAIT`,
|
|
411
|
+
# whose blocking `recv` parks on an empty socket; it leaves O_NONBLOCK alone on 3.3, 3.4 and 4.0,
|
|
412
|
+
# which a worker's SCM_RIGHTS duplicate would otherwise share.
|
|
413
|
+
def peeked_op(connection)
|
|
414
|
+
peeked = connection.socket.recv_nonblock(MAX_REQUEST_BYTES, Socket::MSG_PEEK, exception: false)
|
|
415
|
+
return nil unless peeked.is_a?(String) && peeked.include?("\n")
|
|
416
|
+
|
|
417
|
+
Request.parse(peeked.force_encoding(Encoding::UTF_8)).op
|
|
418
|
+
rescue StandardError
|
|
419
|
+
nil
|
|
420
|
+
end
|
|
421
|
+
|
|
373
422
|
def available_child
|
|
374
423
|
@children.each_value.find(&:available?) || spawn
|
|
375
424
|
end
|
|
@@ -378,34 +427,38 @@ module HotCell
|
|
|
378
427
|
number = free_slot or return nil
|
|
379
428
|
slot = Slot.build(workspace, number)
|
|
380
429
|
supervisor_side, worker_side = UNIXSocket.pair(:STREAM)
|
|
430
|
+
stderr_reader, stderr_writer = IO.pipe
|
|
381
431
|
|
|
382
432
|
# A fork that fails is a host under pressure, not a reason to stop serving. The request stays queued
|
|
383
433
|
# and is either dispatched on a later pass or answered `capacity` when its wait runs out.
|
|
384
434
|
pid = begin
|
|
385
435
|
fork do
|
|
386
|
-
become_worker supervisor_side
|
|
436
|
+
become_worker supervisor_side, stderr_reader, stderr_writer
|
|
387
437
|
Worker.new(slot: slot, configuration: configuration, control: Connection.new(worker_side),
|
|
388
438
|
log: log).run
|
|
389
439
|
end
|
|
390
440
|
rescue SystemCallError => error
|
|
391
441
|
log.write "worker.unforkable", slot: number, error: error.class.name, message: error.message
|
|
392
|
-
supervisor_side.close
|
|
393
|
-
worker_side.close
|
|
442
|
+
[ supervisor_side, worker_side, stderr_reader, stderr_writer ].each(&:close)
|
|
394
443
|
return nil
|
|
395
444
|
end
|
|
396
445
|
|
|
446
|
+
# The write end goes now rather than at the reap. Held here, the pipe never reports end of stream,
|
|
447
|
+
# `sources` keeps a dead worker's read end forever, and a worker that said nothing is
|
|
448
|
+
# indistinguishable from one that has not finished saying it.
|
|
397
449
|
worker_side.close
|
|
450
|
+
stderr_writer.close
|
|
398
451
|
log.write "worker.forked", pid: pid, slot: number
|
|
399
452
|
|
|
400
453
|
@children[number] = Child.build(slot: slot, pid: pid, control: Connection.new(supervisor_side),
|
|
401
|
-
deadline: configuration.limits.deadline)
|
|
454
|
+
deadline: configuration.limits.deadline, stderr: stderr_reader)
|
|
402
455
|
end
|
|
403
456
|
|
|
404
457
|
# Everything the supervisor holds and the worker must not: the listener, the signal pipe, the other
|
|
405
458
|
# children's control sockets, and every connection the supervisor is still holding for somebody else.
|
|
406
459
|
# The connection this worker is about to serve arrives over SCM_RIGHTS a moment from now, so closing
|
|
407
460
|
# the inherited copy here costs nothing and stops it lingering for the worker's whole life.
|
|
408
|
-
def become_worker(supervisor_side)
|
|
461
|
+
def become_worker(supervisor_side, stderr_reader, stderr_writer)
|
|
409
462
|
[ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
|
|
410
463
|
|
|
411
464
|
# Its own process group, so the deadline reaches the tools this request started rather than only the
|
|
@@ -438,9 +491,66 @@ module HotCell
|
|
|
438
491
|
@children.each_value do |child|
|
|
439
492
|
child.control.close
|
|
440
493
|
child.connection&.close
|
|
494
|
+
child.stderr&.close
|
|
441
495
|
end
|
|
442
496
|
@queue.each { |(connection, _)| connection.close }
|
|
443
497
|
@control_pending.each { |pending| pending.connection.close }
|
|
498
|
+
|
|
499
|
+
# fd 2 becomes the pipe, and it stays non-blocking. `IO.pipe` already returns both ends O_NONBLOCK
|
|
500
|
+
# and `reopen` is a dup2, which shares the file description — so the flag would ride along on its
|
|
501
|
+
# own. It is set here anyway, because a decision this load-bearing should be in the code rather than
|
|
502
|
+
# only in a comment, and because it then survives a Ruby that stops handing out non-blocking pipes.
|
|
503
|
+
#
|
|
504
|
+
# A blocking fd 2 would put backpressure into the image-processing path, which was never designed
|
|
505
|
+
# for it: a warning written from inside libvips is a `write(2)` in a C call Ruby cannot interrupt, so
|
|
506
|
+
# the conversion would wait on the supervisor's scheduling — nearest exactly when the host is under
|
|
507
|
+
# pressure and the supervisor is scheduled least. That trade is refused; the conversion path must
|
|
508
|
+
# never wait on the supervisor. What non-blocking loses instead is in docs/LOGS.md, and a test pins
|
|
509
|
+
# the flag so that a well-meaning fix has to argue with it.
|
|
510
|
+
stderr_reader.close
|
|
511
|
+
stderr_writer.fcntl Fcntl::F_SETFL, stderr_writer.fcntl(Fcntl::F_GETFL) | Fcntl::O_NONBLOCK
|
|
512
|
+
$stderr.reopen stderr_writer
|
|
513
|
+
stderr_writer.close
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# One bounded read per pass, never a loop until the pipe is empty: this runs inside the loop that
|
|
517
|
+
# enforces every request's deadline, and the peer is a worker that can print as fast as it likes.
|
|
518
|
+
#
|
|
519
|
+
# End of stream closes the read end. It has to: an EOF pipe is permanently readable, so leaving it in
|
|
520
|
+
# `sources` turns the run loop into a spin between the worker's exit and its reap.
|
|
521
|
+
def drain_stderr(child)
|
|
522
|
+
chunk = begin
|
|
523
|
+
child.stderr.read_nonblock(STDERR_READ_BYTES, exception: false)
|
|
524
|
+
rescue SystemCallError
|
|
525
|
+
nil
|
|
526
|
+
end
|
|
527
|
+
return if chunk == :wait_readable
|
|
528
|
+
return child.stderr.close if chunk.nil?
|
|
529
|
+
|
|
530
|
+
child.capture_stderr chunk
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# A worker's last line sits in the pipe after the process is gone, so the reap reads once more — and
|
|
534
|
+
# under a flat bound rather than to end of stream. End of stream never arrives while a descendant
|
|
535
|
+
# holds the write end, and "until the pipe is momentarily empty" terminates only by winning a race
|
|
536
|
+
# against whoever is writing.
|
|
537
|
+
def drain_stderr_after_exit(child)
|
|
538
|
+
STDERR_FINAL_READS.times do
|
|
539
|
+
break if child.stderr.nil? || child.stderr.closed?
|
|
540
|
+
|
|
541
|
+
chunk = begin
|
|
542
|
+
child.stderr.read_nonblock(STDERR_READ_BYTES, exception: false)
|
|
543
|
+
rescue SystemCallError
|
|
544
|
+
nil
|
|
545
|
+
end
|
|
546
|
+
break if chunk.nil? || chunk == :wait_readable
|
|
547
|
+
|
|
548
|
+
child.capture_stderr chunk
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def child_writing_stderr(source)
|
|
553
|
+
@children.each_value.find { |child| child.stderr.equal?(source) }
|
|
444
554
|
end
|
|
445
555
|
|
|
446
556
|
# Buffered and non-blocking, for the same reason read_control is, and more so: readability means a byte
|
|
@@ -529,6 +639,7 @@ module HotCell
|
|
|
529
639
|
|
|
530
640
|
if message[:deadline]
|
|
531
641
|
child.deadline = narrowed_deadline(message[:deadline])
|
|
642
|
+
child.op = reported_op(message[:op])
|
|
532
643
|
elsif message[:idle]
|
|
533
644
|
return unreadable_report child, "idle report from a worker with no request" unless child.busy?
|
|
534
645
|
|
|
@@ -579,6 +690,11 @@ module HotCell
|
|
|
579
690
|
Codes::PERMANENT_BY_CAUSE.key?(cause)
|
|
580
691
|
end
|
|
581
692
|
|
|
693
|
+
# Bounded to a registered name: this rides an untrusted worker report straight into a log line.
|
|
694
|
+
def reported_op(reported)
|
|
695
|
+
reported if reported.is_a?(String) && Registry.lookup(reported)
|
|
696
|
+
end
|
|
697
|
+
|
|
582
698
|
def outcome_code(reported)
|
|
583
699
|
return reported if reported.is_a?(String) && (reported == "ok" || Codes.known?(reported))
|
|
584
700
|
|
|
@@ -699,11 +815,19 @@ module HotCell
|
|
|
699
815
|
child_reported child.control.socket
|
|
700
816
|
end
|
|
701
817
|
|
|
818
|
+
# Sweep before the last drain, not after. fd 2 is never close-on-exec, so everything this worker
|
|
819
|
+
# spawned holds the write end too — swept first, what those wrote is waiting to be read rather
|
|
820
|
+
# than arriving after the last read. The sweep does not make the pipe quiet: SIGKILL is
|
|
821
|
+
# asynchronous, and a `setsid` descendant is deliberately never reached, so its final bytes are
|
|
822
|
+
# best effort like everything else here.
|
|
702
823
|
sweep_group child
|
|
824
|
+
drain_stderr_after_exit child
|
|
825
|
+
|
|
703
826
|
@children.delete child.slot.number
|
|
704
827
|
answer_for child, status
|
|
705
828
|
discard child
|
|
706
829
|
child.control.close
|
|
830
|
+
child.stderr&.close
|
|
707
831
|
|
|
708
832
|
log.write "worker.reaped", pid: pid, slot: child.slot.number, served: child.served,
|
|
709
833
|
signal: signal_name(status), exit_code: status.exitstatus
|
|
@@ -740,11 +864,17 @@ module HotCell
|
|
|
740
864
|
counters.record Codes::KILLED
|
|
741
865
|
counters.record_kill cause
|
|
742
866
|
|
|
743
|
-
|
|
744
|
-
|
|
867
|
+
captured = child.stderr_field
|
|
868
|
+
|
|
869
|
+
log.write "worker.killed", pid: child.pid, slot: child.slot.number, op: child.op, cause: cause,
|
|
870
|
+
signal: signal_name(status), duration_ms: Clock.ms_since(child.dispatched_at),
|
|
871
|
+
**captured
|
|
745
872
|
|
|
873
|
+
# The capture rides the verdict as well as the log line, so an application logs
|
|
874
|
+
# `killed: crashed (libgomp: ...)` rather than a bare `crashed`. Additive on the wire: `from_wire`
|
|
875
|
+
# reads named keys, so an old client ignores the field and a new one against an old cell meets nil.
|
|
746
876
|
answer child.connection,
|
|
747
|
-
Failure.new(code: Codes::KILLED, cause: cause, signal: signal_name(status)),
|
|
877
|
+
Failure.new(code: Codes::KILLED, cause: cause, signal: signal_name(status), **captured),
|
|
748
878
|
timing: { perform_ms: Clock.ms_since(child.dispatched_at) }
|
|
749
879
|
end
|
|
750
880
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
|
+
require "fcntl"
|
|
4
5
|
require "fileutils"
|
|
5
6
|
|
|
6
7
|
# Fixture operations, so the whole surface can be exercised in milliseconds, with no tool installed and no
|
|
@@ -124,6 +125,26 @@ module HotCell
|
|
|
124
125
|
end
|
|
125
126
|
end
|
|
126
127
|
|
|
128
|
+
# `Exception` so it escapes `serve`'s `rescue StandardError` and reaches `run`, the only path that
|
|
129
|
+
# writes `worker.crashed`.
|
|
130
|
+
class Fatal < HotCell::Operation
|
|
131
|
+
operation "test.fatal"
|
|
132
|
+
|
|
133
|
+
def perform(_inputs, _outputs)
|
|
134
|
+
raise Exception, "a worker cannot answer for this one"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Hangs in the boot hook, which runs before the worker reports its operation.
|
|
139
|
+
class SlowBoot < HotCell::Operation
|
|
140
|
+
operation "test.slow_boot"
|
|
141
|
+
before_worker_boot { sleep 60 }
|
|
142
|
+
|
|
143
|
+
def perform(_inputs, _outputs)
|
|
144
|
+
{}
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
127
148
|
class Undecodable < HotCell::Operation
|
|
128
149
|
operation "test.undecodable"
|
|
129
150
|
|
|
@@ -461,5 +482,57 @@ module HotCell
|
|
|
461
482
|
{ slept: seconds, pid: Process.pid }
|
|
462
483
|
end
|
|
463
484
|
end
|
|
485
|
+
|
|
486
|
+
# Writes to fd 2 and then either dies the way a C library does — `exit()` with no Ruby exception, so
|
|
487
|
+
# there is no `worker.crashed` line and nothing on the connection — or returns normally, which is the
|
|
488
|
+
# warning a cell deliberately does not report. `noise:` goes out before `text:`, so a test can ask which
|
|
489
|
+
# end of an oversized transcript was kept.
|
|
490
|
+
class StderrWriter < HotCell::Operation
|
|
491
|
+
operation "test.stderr_writer"
|
|
492
|
+
|
|
493
|
+
def perform(_inputs, _outputs, text:, noise: 0, fatal: false)
|
|
494
|
+
$stderr.write "noise\n" * noise
|
|
495
|
+
$stderr.write text
|
|
496
|
+
$stderr.flush
|
|
497
|
+
exit! 1 if fatal
|
|
498
|
+
|
|
499
|
+
{ wrote: text.bytesize }
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Bytes that are not valid UTF-8, which a payload cannot carry — so this is a fixture rather than an
|
|
504
|
+
# argument to StderrWriter. A decoder writing a filename out of a hostile file is where these come from.
|
|
505
|
+
class GarbledStderr < HotCell::Operation
|
|
506
|
+
operation "test.garbled_stderr"
|
|
507
|
+
|
|
508
|
+
def perform(_inputs, _outputs)
|
|
509
|
+
$stderr.write "libgomp: \xFF\xFE failed\n".b
|
|
510
|
+
$stderr.flush
|
|
511
|
+
exit! 1
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Reports whether fd 2 is non-blocking, which is the load-bearing decision behind the capture: a
|
|
516
|
+
# blocking fd 2 would put the supervisor's scheduling in the middle of a libvips `write(2)`.
|
|
517
|
+
class StderrFlags < HotCell::Operation
|
|
518
|
+
operation "test.stderr_flags"
|
|
519
|
+
|
|
520
|
+
def perform(_inputs, _outputs)
|
|
521
|
+
{ nonblock: ($stderr.fcntl(Fcntl::F_GETFL) & Fcntl::O_NONBLOCK).positive? }
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# A tool that keeps writing to fd 2 after the worker itself is gone. fd 2 is never close-on-exec, so
|
|
526
|
+
# everything a worker spawned holds the write end, and the pipe reports no end of stream until the
|
|
527
|
+
# reap's group sweep kills them.
|
|
528
|
+
class StderrDescendant < HotCell::Operation
|
|
529
|
+
operation "test.stderr_descendant"
|
|
530
|
+
|
|
531
|
+
def perform(_inputs, _outputs)
|
|
532
|
+
spawn "sh", "-c", "while :; do echo from the descendant >&2; done"
|
|
533
|
+
sleep 0.5
|
|
534
|
+
exit! 1
|
|
535
|
+
end
|
|
536
|
+
end
|
|
464
537
|
end
|
|
465
538
|
end
|
data/lib/hot_cell/worker.rb
CHANGED
|
@@ -21,6 +21,7 @@ module HotCell
|
|
|
21
21
|
@log = log
|
|
22
22
|
@booted = nil
|
|
23
23
|
@effective = {}
|
|
24
|
+
@op = nil
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# exit! rather than exit, so that no finalizer and no library teardown ever runs. There is deliberately no
|
|
@@ -48,8 +49,8 @@ module HotCell
|
|
|
48
49
|
|
|
49
50
|
exit! 0
|
|
50
51
|
rescue Exception => error
|
|
51
|
-
log.write "worker.crashed", pid: Process.pid, slot: slot.number,
|
|
52
|
-
message: Failure.sanitize(error.message)
|
|
52
|
+
log.write "worker.crashed", pid: Process.pid, slot: slot.number, op: @op,
|
|
53
|
+
error: error.class.name, message: Failure.sanitize(error.message)
|
|
53
54
|
exit! 1
|
|
54
55
|
end
|
|
55
56
|
|
|
@@ -88,6 +89,9 @@ module HotCell
|
|
|
88
89
|
# called recvmsg, so the caller's own descriptors are still queued on it and this worker's recvmsg
|
|
89
90
|
# is what installs them.
|
|
90
91
|
def await_dispatch
|
|
92
|
+
# Not in `serve`'s ensure: `worker.crashed` is written from `run`, past it.
|
|
93
|
+
@op = nil
|
|
94
|
+
|
|
91
95
|
line, descriptors = control.receive_message(limit: DISPATCH_BYTES)
|
|
92
96
|
return nil if line.nil?
|
|
93
97
|
|
|
@@ -174,6 +178,7 @@ module HotCell
|
|
|
174
178
|
|
|
175
179
|
def handle(line, received, timing)
|
|
176
180
|
request = Request.parse(line)
|
|
181
|
+
@op = request.op
|
|
177
182
|
|
|
178
183
|
unless request.current_version?
|
|
179
184
|
return refuse("protocol", request.version_mismatch, timing)
|
|
@@ -264,8 +269,22 @@ module HotCell
|
|
|
264
269
|
# The supervisor enforces the deadline and never reads a request, so it cannot know that this
|
|
265
270
|
# operation asked for less than the cell's maximum. The worker is the only thing that knows, and it
|
|
266
271
|
# says so before it touches an untrusted byte.
|
|
272
|
+
#
|
|
273
|
+
# The name rides along because a killed worker cannot write its own `worker.killed`.
|
|
267
274
|
def report_deadline(operation)
|
|
268
|
-
|
|
275
|
+
named = { deadline: effective(operation).deadline, op: @op }
|
|
276
|
+
|
|
277
|
+
tell(**(fits?(named) ? named : named.merge(op: nil)))
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The supervisor drops an over-limit report whole, so an oversized name would take the narrowed
|
|
281
|
+
# deadline with it. Measured on the encoded line and not the name's length, because escaping decides:
|
|
282
|
+
# JSON writes a NUL as six bytes. Dropped rather than truncated, which can end mid-character and
|
|
283
|
+
# raise out of `tell`, or match the registry as a different operation.
|
|
284
|
+
def fits?(message)
|
|
285
|
+
(JSON.generate(message) << "\n").bytesize <= DISPATCH_BYTES
|
|
286
|
+
rescue StandardError
|
|
287
|
+
false
|
|
269
288
|
end
|
|
270
289
|
|
|
271
290
|
# The cause travels with the code so the supervisor can still count a kill by cause. It used to read
|
|
@@ -299,7 +318,7 @@ module HotCell
|
|
|
299
318
|
|
|
300
319
|
connection.write_line line_for(response)
|
|
301
320
|
rescue SystemCallError, IOError
|
|
302
|
-
log.write "request.abandoned", pid: Process.pid, slot: slot.number
|
|
321
|
+
log.write "request.abandoned", pid: Process.pid, slot: slot.number, op: @op
|
|
303
322
|
end
|
|
304
323
|
|
|
305
324
|
def line_for(response)
|
|
@@ -311,7 +330,8 @@ module HotCell
|
|
|
311
330
|
def record(response, timing)
|
|
312
331
|
return if response.nil?
|
|
313
332
|
|
|
314
|
-
log.write "request", pid: Process.pid, slot: slot.number,
|
|
333
|
+
log.write "request", pid: Process.pid, slot: slot.number, op: @op,
|
|
334
|
+
code: response.failure&.code || "ok",
|
|
315
335
|
permanent: response.failure&.permanent?,
|
|
316
336
|
outcome: response.failure ? "failure" : "success",
|
|
317
337
|
duration_ms: timing.elapsed_ms, timing: response.timing
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hotcell-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Dalessio
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - '='
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.3.1
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - '='
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.3.1
|
|
26
26
|
description: |
|
|
27
27
|
Runs a HotCell container. A supervisor listens on two Unix sockets, forks a worker for each request,
|
|
28
28
|
and enforces a wall clock deadline and resource limits on it. Write the work as a subclass of
|
|
@@ -61,8 +61,8 @@ licenses:
|
|
|
61
61
|
- MIT
|
|
62
62
|
metadata:
|
|
63
63
|
homepage_uri: https://github.com/basecamp/hotcell
|
|
64
|
-
source_code_uri: https://github.com/basecamp/hotcell/tree/v0.
|
|
65
|
-
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.
|
|
64
|
+
source_code_uri: https://github.com/basecamp/hotcell/tree/v0.3.1/hotcell-server
|
|
65
|
+
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.3.1/CHANGELOG.md
|
|
66
66
|
bug_tracker_uri: https://github.com/basecamp/hotcell/issues
|
|
67
67
|
rubygems_mfa_required: 'true'
|
|
68
68
|
rdoc_options: []
|