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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50d90e8b9f7ecb2a139d9c678a3cfdb228288d590f33cdf19390b6cbd92a924e
4
- data.tar.gz: 66c3bcb911c378507bc9623cb1badc8c3372b6a58c3cd0312eae81c72b003877
3
+ metadata.gz: 9b2d1b6a8296d8833188c1414bfeb70d7031c8601d520361b10110e832461992
4
+ data.tar.gz: ec7983987f989d78eeeefee5b3fa8b5c5a860d3ae1022ce414172ea553d64938
5
5
  SHA512:
6
- metadata.gz: c489f02a8b5670abda9df8c52f62fc4657a725abf01c6e49a39ad1065671535f96942e9ed5168fed5062d9b1ce3f55cef5aba0c2dfab2ec2f1c9c35b14980490
7
- data.tar.gz: 5b633520243d9f70ab9adfaa1f2427516012ca477e3acd4d5917cbfe5a02ca01dc830e6d6855faeda5e11e8d3b8978634cadfa39712938aeeaeb6560e9f8032c
6
+ metadata.gz: 112abfea26523d9592dd208455e70a824937c910f98d910990b2359e0530f8f9fd01348a57b4b067a80ee9a77cf64d36aaf7e4bf704c9cf9c88d99eb324e14f1
7
+ data.tar.gz: 99fbe2141dee8d99e385b3fe825476366bb56988f7ca508a8ff961d302052b3ab656b07b28cce2513e45012e8a8903aac916f62e31931871fb4a55d4dceb2314
@@ -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
- # pointed at a cell that does not carry the operation it wants, which is otherwise an `unsupported` on
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. The one line
69
- # that can exceed it is cell.boot's inventory, written once before the loop enforces anything and
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
@@ -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 as `/dev/fd/N` (Descriptor#fd_path) and no
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module HotCell
4
4
  module Server
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
@@ -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
- pending ? read_control(pending) : child_reported(source)
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
- log.write "worker.killed", pid: child.pid, slot: child.slot.number, cause: cause,
744
- signal: signal_name(status), duration_ms: Clock.ms_since(child.dispatched_at)
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
@@ -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, error: error.class.name,
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
- tell deadline: effective(operation).deadline
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, code: response.failure&.code || "ok",
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.2.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.2.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.2.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.2.0/hotcell-server
65
- changelog_uri: https://github.com/basecamp/hotcell/blob/v0.2.0/CHANGELOG.md
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: []