hotcell-server 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db6e168c58fc1e84ea4411d8935574649f5464059f1e785d43d2388440bcef21
4
- data.tar.gz: 9c2e43635ad2233707d59aa48d76f4b20823ae64bf67d7fc651f8e6d8178a88b
3
+ metadata.gz: 96b9d4b602f5a5430605a97aec5db12ea0a67a6cdac0b1fb42e5174c2fa447a7
4
+ data.tar.gz: 4beed8c182fa5cfbb1bb4713ff3cb6e19772c29ac4e4dfec6d5456b217be054d
5
5
  SHA512:
6
- metadata.gz: 81c839e7cd18af7add06ba34c0e3953787734eaf7906f9c0c6eaf7144e039d4d5dc7caef119fb9d98c006a7c09ce93b99576fa615cddff65c1868bb102a1567a
7
- data.tar.gz: 0744a62b685fd7e96b39f9169869813e7ccc11e9331052742cc3a017be1464090aad6ee6cc0a36de4ec52e0e2802ced1d04215230a2d186e23c6ad2cfd5d41d1
6
+ metadata.gz: 4974036dd70e168a833dbb03cf0aef47d6ce0be264ddc8327e5b4d6265c1b65d85a55767815cbf2471e6c9a534a910dcc8597bc6913cf0d1c5bec93d4d198c9d
7
+ data.tar.gz: 433c2c6f0e55f72b849f67a3abb9bc55129a0d5ececb9fa9b8c50c277dabee74f69d2ce95259d30c9fc0d0bbfd9b7db48cdcbd061be85677d98aaf3c76f8dd91
@@ -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
@@ -196,8 +196,12 @@ module HotCell
196
196
  kept
197
197
  end
198
198
 
199
+ # The OpenMP variables come from the cell's environment rather than being named here: the number
200
+ # belongs to the image, which is configured to match the container's CPU quota. Without them
201
+ # `unsetenv_others: true` would hand an exec'd tool the pool the image's bound was meant to take away.
199
202
  def tool_environment(overrides)
200
- { "HOME" => ENV["HOME"], "PATH" => ENV["PATH"], "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8" }
203
+ { "HOME" => ENV["HOME"], "PATH" => ENV["PATH"], "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8",
204
+ "OMP_NUM_THREADS" => ENV["OMP_NUM_THREADS"], "OMP_THREAD_LIMIT" => ENV["OMP_THREAD_LIMIT"] }
201
205
  .merge(overrides.transform_keys(&:to_s))
202
206
  .compact
203
207
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module HotCell
4
4
  module Server
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/hot_cell/slot.rb CHANGED
@@ -11,8 +11,7 @@ module HotCell
11
11
  #
12
12
  # **A slot has one directory per request and it is that request's `$HOME`.** It is created when the
13
13
  # request starts and removed when the request ends, so nothing a tool writes under `$HOME` reaches the
14
- # next request on this slot. The name is stable and the directory behind it is not, which is why a
15
- # reused worker reports the same path twice.
14
+ # next request on this slot.
16
15
  #
17
16
  # This directory used to survive, to give a tool with an expensive per-user profile a warm one. That is
18
17
  # withdrawn. What a tool reads from `$HOME` is configuration, and for the toolchains a cell carries
@@ -22,23 +21,48 @@ module HotCell
22
21
  # `max_requests_per_worker: 1` is supposed to hold. adr/0003 records the reversal and adr/0002 the
23
22
  # reasoning it supersedes.
24
23
  #
25
- # There is one directory and not two. A request's staged inputs and outputs are named inside `$HOME`
26
- # rather than in a scratch directory of their own, because the two had the same lifetime and the same
27
- # owner once the home stopped surviving. Staging used to create its directory on demand, which is what
28
- # kept a descriptor-only operation from paying for one; `$HOME` has to exist for every request either
29
- # way, so that laziness bought nothing and is gone with it.
24
+ # **The name is fresh for every request, and that is what makes the removal above a guarantee rather than
25
+ # an intention.** A stable name only holds while the deletion behind it works, and the tool that filled
26
+ # the directory runs as the user that owns it: `chmod 0500` on a directory it has written makes its own
27
+ # configuration unremovable, and the same mode on the slot directory makes it unrenameable too. Both
28
+ # cleanups answer false and both callers log — and a stable name then handed the next request the tree
29
+ # that had just refused to go. A name no earlier request has held is not a name an earlier request could
30
+ # have prepared, so what a failed cleanup now costs is disk rather than the isolation the cell is for.
31
+ #
32
+ # The mode of the slot directory is reasserted for the same reason. It is the one name here that is
33
+ # predictable, so it is the one an earlier request can lock, and a mode on a directory this uid owns is
34
+ # ours to put back.
35
+ #
36
+ # **This bounds what an earlier request left behind, and not what a live one is doing.** Workers share a
37
+ # uid and `0700` is the owner's own mode, so a concurrent sibling — or a `setsid` descendant of a finished
38
+ # request, which process groups do not contain — can still write into a home the moment it exists. The
39
+ # slot directory itself can be renamed aside and replaced, and `chmod` follows what it finds. Both are the
40
+ # residuals `docs/DESIGN.md` records under worker isolation, and neither is closed here.
41
+ #
42
+ # There is one directory per request and not two. A request's staged inputs and outputs are named inside
43
+ # `$HOME` rather than in a scratch directory of their own, because the two had the same lifetime and the
44
+ # same owner once the home stopped surviving. Staging used to create its directory on demand, which is
45
+ # what kept a descriptor-only operation from paying for one; `$HOME` has to exist for every request
46
+ # either way, so that laziness bought nothing and is gone with it.
30
47
  #
31
48
  # The filesystem behaviour belongs here rather than in the two processes that call it. The directory is
32
49
  # removed from both — the worker before it answers, the supervisor at finish and at reap — so the guard
33
50
  # and the swallowed SystemCallError are a rule that has to hold on both sides of a fork, and it had a
34
51
  # copy on each.
35
- Slot = Struct.new(:number, :home) do
52
+ Slot = Struct.new(:number, :directory, :home) do
36
53
  def self.build(workspace, number)
37
- new number, File.join(workspace, number.to_s, "home")
54
+ new number, File.join(workspace, number.to_s)
38
55
  end
39
56
 
57
+ # A name no request has held before, so nothing an earlier one did to the tree it was given reaches this
58
+ # one. The suffix is random rather than a counter for the reason the discarded name's is: the previous
59
+ # request could write to this directory, and a predictable name is one it can pre-create.
40
60
  def make_home
41
- FileUtils.mkdir_p home, mode: 0o700
61
+ FileUtils.mkdir_p directory, mode: 0o700
62
+ FileUtils.chmod 0o700, directory
63
+
64
+ self.home = File.join(directory, "home-#{SecureRandom.hex(8)}")
65
+ Dir.mkdir home, 0o700
42
66
  home
43
67
  end
44
68
 
@@ -55,10 +79,11 @@ module HotCell
55
79
  # response with a crash, and the supervisor calls discard_home from finish and reap, where nothing above
56
80
  # rescues anything and a raise stops the cell with every request it holds.
57
81
  def remove_home
58
- FileUtils.remove_entry home if Dir.exist?(home)
82
+ return true if home.nil?
83
+ return false unless remove_tree(home)
84
+
85
+ self.home = nil
59
86
  true
60
- rescue SystemCallError
61
- false
62
87
  end
63
88
 
64
89
  # **The supervisor renames rather than deletes, and that is a scheduling decision.**
@@ -73,34 +98,74 @@ module HotCell
73
98
  # where the unlinking costs nobody's latency.
74
99
  #
75
100
  # The destination carries a random suffix rather than a counter, because the tool that filled the
76
- # directory runs as this user and can write to the slot's workspace. A predictable name lets it
101
+ # directory runs as this user and can write to the slot's directory. A predictable name lets it
77
102
  # pre-create a colliding entry, fail the rename, and send the supervisor into the recursive delete the
78
103
  # rename exists to avoid. On any rename failure the tree is left where it is, for a later worker's own
79
104
  # cleanup to remove off the hot path. The supervisor never deletes a tree inline, whatever goes wrong.
105
+ # It renames what is there rather than the name it is holding, because the supervisor is the caller that
106
+ # matters and it does not know the name. `home` is assigned in the worker after the fork, so the
107
+ # supervisor's copy of the slot is still nil when it discards a killed worker's tree. At most one worker
108
+ # holds a slot at a time, so everything matching `home-*` under it is that worker's and nobody else's.
80
109
  def discard_home
81
- return true unless Dir.exist?(home)
110
+ FileUtils.chmod 0o700, directory if Dir.exist?(directory)
82
111
 
83
- File.rename home, "#{home}.discarded-#{Process.pid}-#{SecureRandom.hex(8)}"
112
+ Dir.glob(File.join(directory, "home-*")).each do |path|
113
+ File.rename path, File.join(directory, "discarded-#{Process.pid}-#{SecureRandom.hex(8)}")
114
+ end
115
+
116
+ self.home = nil
84
117
  true
85
118
  rescue SystemCallError
86
119
  false
87
120
  end
88
121
 
89
122
  # Nothing here is created at boot, because nothing survives a request. This only clears what an earlier
90
- # boot left behind.
123
+ # boot left behind — the whole slot directory, since the names inside it are an earlier boot's and not
124
+ # this one's to reconstruct.
125
+ #
126
+ # `Dir.exist?` is not the guard, because it follows symlinks and answers false for a dangling one. An
127
+ # entry a tool left in the slot's place is exactly what this has to remove, and it used to survive the
128
+ # sweep and raise from every later `make_home`.
91
129
  def prepare
92
- # Both, rather than short-circuiting: a home that could not be removed must not stop the sweep.
93
- cleared = remove_home
94
- sweep && cleared
130
+ remove_tree directory
95
131
  end
96
132
 
97
133
  # Unlinks whatever discard_home renamed out of the way. Partial progress is fine: a sweep killed
98
134
  # part-way leaves fewer entries for the next one, so this converges rather than repeating.
99
135
  def sweep
100
- Dir.glob("#{home}.discarded-*").each { |path| FileUtils.remove_entry path }
101
- true
136
+ Dir.glob(File.join(directory, "discarded-*")).map { |path| remove_tree(path) }.all?
102
137
  rescue SystemCallError
138
+ # The glob itself can fail, because the slot directory is a name a tool can replace — a symlink loop
139
+ # in its place answers ELOOP here rather than for any one entry. This runs from the worker's ensure,
140
+ # where a raise would replace the caller's response with a crash.
103
141
  false
104
142
  end
143
+
144
+ private
145
+ # **A mode is the only thing a tool needs to make its own tree unremovable, and a mode on a tree this
146
+ # uid owns is ours to put back.** `chmod 0500` on a directory a conversion wrote is enough to fail the
147
+ # recursive delete underneath it, and the delete failing used to be the end of it: one tree per
148
+ # request stayed on the tmpfs until the container ended. The repair is not a permission the process
149
+ # gains, it is one it never lost.
150
+ #
151
+ # Repair only after a failure, never before, so the common request pays for a walk of its own tree
152
+ # once rather than twice. `force:` on the chmod because a partial repair that removes most of the tree
153
+ # is better than none, and the remove that follows is what reports the outcome either way.
154
+ def remove_tree(path)
155
+ return true unless File.exist?(path) || File.symlink?(path)
156
+
157
+ FileUtils.remove_entry path
158
+ true
159
+ rescue SystemCallError
160
+ repair_and_remove path
161
+ end
162
+
163
+ def repair_and_remove(path)
164
+ FileUtils.chmod_R 0o700, path, force: true
165
+ FileUtils.remove_entry path
166
+ true
167
+ rescue SystemCallError
168
+ false
169
+ end
105
170
  end
106
171
  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
 
@@ -90,31 +113,6 @@ module HotCell
90
113
  # bytes than Linux, and control.sock is the longer of the two names, so it overflows first.
91
114
  SUN_PATH_MAX = RUBY_PLATFORM.include?("darwin") ? 104 : 108
92
115
 
93
- # The signals this cell can attribute to the request the worker was holding. XFSZ is that worker passing
94
- # RLIMIT_FSIZE, and SEGV, ABRT and TRAP are how libvips and GLib die on their own allocation failures —
95
- # libvips dereferences null after printing the correct diagnostic, and g_malloc aborts.
96
- #
97
- # These three are the worker hitting its own per-worker RLIMIT_DATA, which is a property of the input
98
- # this worker held, so the same bytes do it again and the verdict is permanent. `Codes` says a signal
99
- # tells how a process died and never why, so a signal is transient by default — and that is not in
100
- # conflict with a permanent verdict here, because aggregate pressure the worker did not cause arrives as
101
- # SIGKILL, not as these. The two are different signals, and SIGKILL is excluded below.
102
- #
103
- # SIGKILL is deliberately absent, and its absence is the point. The supervisor's own deadline kill is
104
- # already named by `killed_for`, so a SIGKILL reaching this table came from somewhere this process
105
- # cannot see: a cgroup OOM chosen on aggregate pressure, or a sibling worker, which shares a uid and is
106
- # not prevented from signalling. Reading it as this request's memory condemned an input for someone
107
- # else's pressure.
108
- #
109
- # Anything not here is `crashed`, which is also where a worker that exited without a signal lands. They
110
- # were two names, `signal` and `crashed`, for one amount of knowledge: the worker died and nothing says
111
- # why. One name is honest about that.
112
- SIGNAL_CAUSES = {
113
- "XFSZ" => Codes::FSIZE,
114
- "SEGV" => Codes::MEMORY,
115
- "ABRT" => Codes::MEMORY,
116
- "TRAP" => Codes::MEMORY,
117
- }.freeze
118
116
 
119
117
  SOCKETS = [ "work.sock", "control.sock" ].freeze
120
118
 
@@ -130,6 +128,12 @@ module HotCell
130
128
  # far above any real scrape rate rather than as a throttle.
131
129
  CONTROL_BACKLOG = 64
132
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
+
133
137
  attr_reader :configuration, :counters, :log, :directory, :workspace
134
138
 
135
139
  def initialize(directory:, workspace: nil, configuration: HotCell.configuration, log: Log.new,
@@ -188,6 +192,7 @@ module HotCell
188
192
  def sources
189
193
  [ @signals ].tap do |list|
190
194
  list.concat @children.each_value.map { |child| child.control.socket }.reject(&:closed?)
195
+ list.concat @children.each_value.filter_map(&:stderr).reject(&:closed?)
191
196
  list.concat @control_pending.map { |pending| pending.connection.socket }.reject(&:closed?)
192
197
  list.push @work, @control unless @stopping
193
198
  end
@@ -212,8 +217,13 @@ module HotCell
212
217
  when @work then accept_work
213
218
  when @control then accept_control
214
219
  else
215
- pending = pending_control(source)
216
- 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
217
227
  end
218
228
  end
219
229
 
@@ -383,7 +393,7 @@ module HotCell
383
393
  true
384
394
  rescue SystemCallError, IOError => error
385
395
  log.write "worker.undispatchable", pid: child.pid, slot: child.slot.number,
386
- error: error.class.name
396
+ op: peeked_op(connection), error: error.class.name
387
397
 
388
398
  # Released rather than finished: this is the only path that hands the connection back to be answered
389
399
  # here, so the client connection must not be closed on the way out. `retire` closes the worker's
@@ -395,6 +405,20 @@ module HotCell
395
405
  false
396
406
  end
397
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
+
398
422
  def available_child
399
423
  @children.each_value.find(&:available?) || spawn
400
424
  end
@@ -403,34 +427,38 @@ module HotCell
403
427
  number = free_slot or return nil
404
428
  slot = Slot.build(workspace, number)
405
429
  supervisor_side, worker_side = UNIXSocket.pair(:STREAM)
430
+ stderr_reader, stderr_writer = IO.pipe
406
431
 
407
432
  # A fork that fails is a host under pressure, not a reason to stop serving. The request stays queued
408
433
  # and is either dispatched on a later pass or answered `capacity` when its wait runs out.
409
434
  pid = begin
410
435
  fork do
411
- become_worker supervisor_side
436
+ become_worker supervisor_side, stderr_reader, stderr_writer
412
437
  Worker.new(slot: slot, configuration: configuration, control: Connection.new(worker_side),
413
438
  log: log).run
414
439
  end
415
440
  rescue SystemCallError => error
416
441
  log.write "worker.unforkable", slot: number, error: error.class.name, message: error.message
417
- supervisor_side.close
418
- worker_side.close
442
+ [ supervisor_side, worker_side, stderr_reader, stderr_writer ].each(&:close)
419
443
  return nil
420
444
  end
421
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.
422
449
  worker_side.close
450
+ stderr_writer.close
423
451
  log.write "worker.forked", pid: pid, slot: number
424
452
 
425
453
  @children[number] = Child.build(slot: slot, pid: pid, control: Connection.new(supervisor_side),
426
- deadline: configuration.limits.deadline)
454
+ deadline: configuration.limits.deadline, stderr: stderr_reader)
427
455
  end
428
456
 
429
457
  # Everything the supervisor holds and the worker must not: the listener, the signal pipe, the other
430
458
  # children's control sockets, and every connection the supervisor is still holding for somebody else.
431
459
  # The connection this worker is about to serve arrives over SCM_RIGHTS a moment from now, so closing
432
460
  # the inherited copy here costs nothing and stops it lingering for the worker's whole life.
433
- def become_worker(supervisor_side)
461
+ def become_worker(supervisor_side, stderr_reader, stderr_writer)
434
462
  [ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
435
463
 
436
464
  # Its own process group, so the deadline reaches the tools this request started rather than only the
@@ -463,9 +491,66 @@ module HotCell
463
491
  @children.each_value do |child|
464
492
  child.control.close
465
493
  child.connection&.close
494
+ child.stderr&.close
466
495
  end
467
496
  @queue.each { |(connection, _)| connection.close }
468
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) }
469
554
  end
470
555
 
471
556
  # Buffered and non-blocking, for the same reason read_control is, and more so: readability means a byte
@@ -554,10 +639,11 @@ module HotCell
554
639
 
555
640
  if message[:deadline]
556
641
  child.deadline = narrowed_deadline(message[:deadline])
642
+ child.op = reported_op(message[:op])
557
643
  elsif message[:idle]
558
644
  return unreadable_report child, "idle report from a worker with no request" unless child.busy?
559
645
 
560
- finish child, message[:code]
646
+ finish child, message[:code], message[:cause]
561
647
  end
562
648
  rescue MessageError => error
563
649
  unreadable_report child, Failure.sanitize(error.message)
@@ -568,10 +654,13 @@ module HotCell
568
654
  # off the hot path, because the supervisor must never delete one inline. It is not tolerated silently:
569
655
  # the random suffix exists because a tool running as this user can pre-create a colliding name, so a
570
656
  # rename that fails is the shape of that attempt as well as of an ordinary error.
657
+ # The slot directory rather than the request's home, because the supervisor does not know the home. It
658
+ # is named in the worker after the fork, so this copy of the slot holds nil for the whole life of the
659
+ # child — and logging it said `null` on every failure, which is worse than saying nothing.
571
660
  def discard(child)
572
661
  return if child.slot.discard_home
573
662
 
574
- log.write "slot.undiscarded", pid: child.pid, slot: child.slot.number, home: child.slot.home
663
+ log.write "slot.undiscarded", pid: child.pid, slot: child.slot.number, home: child.slot.directory
575
664
  end
576
665
 
577
666
  def unreadable_report(child, message)
@@ -579,8 +668,9 @@ module HotCell
579
668
  nil
580
669
  end
581
670
 
582
- def finish(child, code)
671
+ def finish(child, code, cause = nil)
583
672
  counters.record outcome_code(code)
673
+ counters.record_kill cause if reported_cause?(code, cause)
584
674
  child.finished
585
675
  discard child
586
676
 
@@ -592,6 +682,19 @@ module HotCell
592
682
  # NoMethodError on `to_sym` past `apply_report`'s rescue and took the cell down, and a stream of unique
593
683
  # strings grew the counters without bound. An unknown code is recorded, so a misreporting worker is
594
684
  # visible rather than silent, but under one fixed bucket.
685
+ # A worker reports its own kill cause now, so this is a value from a process that may be compromised.
686
+ # It is checked against the known causes rather than passed through, because `record_kill` interns it
687
+ # as a symbol and an unchecked one is an unbounded symbol table keyed by whatever a tool decides.
688
+ def reported_cause?(code, cause)
689
+ outcome_code(code) == Codes::KILLED && cause.is_a?(String) &&
690
+ Codes::PERMANENT_BY_CAUSE.key?(cause)
691
+ end
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
+
595
698
  def outcome_code(reported)
596
699
  return reported if reported.is_a?(String) && (reported == "ok" || Codes.known?(reported))
597
700
 
@@ -712,11 +815,19 @@ module HotCell
712
815
  child_reported child.control.socket
713
816
  end
714
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.
715
823
  sweep_group child
824
+ drain_stderr_after_exit child
825
+
716
826
  @children.delete child.slot.number
717
827
  answer_for child, status
718
828
  discard child
719
829
  child.control.close
830
+ child.stderr&.close
720
831
 
721
832
  log.write "worker.reaped", pid: pid, slot: child.slot.number, served: child.served,
722
833
  signal: signal_name(status), exit_code: status.exitstatus
@@ -725,26 +836,45 @@ module HotCell
725
836
  nil
726
837
  end
727
838
 
728
- # A killed worker cannot report its own death, because RLIMIT_FSIZE and the deadline KILL are
729
- # enforced by a signal. So the supervisor holds its copy of every dispatched connection and writes
730
- # the verdict itself. Without this the cold side sees a bare end of stream and cannot tell a limit
839
+ # A killed worker cannot report its own death, because the deadline KILL is enforced by a signal. So
840
+ # the supervisor holds its copy of every dispatched connection and writes the verdict itself. Without
841
+ # this the cold side sees a bare end of stream and cannot tell a limit
731
842
  # breach from a crash.
732
843
  # A worker still holding a connection at reap time never answered: it reports itself idle after writing,
733
844
  # and that report is drained above. So this is the only thing that can answer, and whether it says the
734
845
  # input did this or the cell did turns on how the worker died.
846
+ # **The only cause this can authenticate is its own.** `killed_for` is the deadline kill this
847
+ # supervisor sent, and it is the one thing here that knows why a worker died. Everything else is a
848
+ # wait status, which says how — and workers share a uid, so any signal in one may have come from a
849
+ # sibling rather than from the kernel. Reading XFSZ, SEGV, ABRT or TRAP as this request's file size or
850
+ # memory let one compromised worker write a permanent verdict against another request's unrelated
851
+ # input, which Active Storage then kept. `Codes` already stated the rule this broke.
852
+ #
853
+ # The verdicts themselves are not gone, they moved to where they can be earned: the worker answers
854
+ # `memory` when it catches NoMemoryError and `fsize` when a write of its own returns EFBIG, on the
855
+ # connection it is holding. See Worker#disarm_file_size_signal.
856
+ #
857
+ # What that buys is bounded, and it is worth being exact. The supervisor is no longer an instrument
858
+ # for one worker to condemn another's input. A cell compromised outright still answers whatever it
859
+ # likes on a connection it holds, which is the socket-theft residual under "Worker isolation".
735
860
  def answer_for(child, status)
736
861
  return child.connection&.close unless child.busy?
737
862
 
738
- cause = child.killed_for ||
739
- SIGNAL_CAUSES.fetch(signal_name(status), Codes::CRASHED)
863
+ cause = child.killed_for || Codes::CRASHED
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
 
@@ -860,7 +990,7 @@ module HotCell
860
990
  slot = Slot.build(workspace, number)
861
991
  next if slot.prepare
862
992
 
863
- log.write "slot.uncleaned", slot: number, home: slot.home,
993
+ log.write "slot.uncleaned", slot: number, home: slot.directory,
864
994
  message: "an earlier boot's files are still here"
865
995
  end
866
996
  end
@@ -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
 
@@ -164,6 +185,46 @@ module HotCell
164
185
  end
165
186
  end
166
187
 
188
+ class SignalsSibling < HotCell::Operation
189
+ operation "test.signals_sibling"
190
+
191
+ # Workers share a uid and a pid namespace, so one finds another by looking for a process the
192
+ # supervisor also fathered. This is the reproducer for the forged verdict: nothing here touches the
193
+ # victim's input, and the victim is holding an unrelated one.
194
+ def perform(_inputs, _outputs, signal:)
195
+ sibling = siblings.first
196
+ Process.kill signal, sibling if sibling
197
+
198
+ { signalled: sibling }
199
+ end
200
+
201
+ private
202
+ # Two ways to ask the same question, because the suite runs on macOS as well and only one of them
203
+ # has /proc. An attacker inside a cell has whichever the image gives it; the point of the reproducer
204
+ # is that the answer is obtainable at all.
205
+ def siblings
206
+ pids = Dir.exist?("/proc") ? procfs_children : ps_children
207
+
208
+ pids.reject { |pid| pid == Process.pid }
209
+ end
210
+
211
+ def procfs_children
212
+ Dir.glob("/proc/[0-9]*").filter_map do |path|
213
+ status = File.read(File.join(path, "status"))
214
+ File.basename(path).to_i if status[/^PPid:\s+(\d+)/, 1].to_i == Process.ppid
215
+ rescue SystemCallError
216
+ nil
217
+ end
218
+ end
219
+
220
+ def ps_children
221
+ `ps -A -o pid=,ppid=`.lines.filter_map do |line|
222
+ pid, ppid = line.split.map(&:to_i)
223
+ pid if ppid == Process.ppid
224
+ end
225
+ end
226
+ end
227
+
167
228
  # Spawns a process and does not wait for it, the way a worker that crashed mid-request would leave a
168
229
  # tool behind. The spawned process inherits the worker's process group, so the supervisor's group sweep
169
230
  # at reap is what must kill it — nothing else is watching it, and it has no deadline. Returns the pid so
@@ -421,5 +482,57 @@ module HotCell
421
482
  { slept: seconds, pid: Process.pid }
422
483
  end
423
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
424
537
  end
425
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
@@ -40,7 +41,7 @@ module HotCell
40
41
  # It swallows nothing: `exit! 1` runs whatever was caught.
41
42
  def run
42
43
  configuration.limits.apply
43
- ENV["HOME"] = slot.home
44
+ disarm_file_size_signal
44
45
 
45
46
  while (dispatch = await_dispatch)
46
47
  serve(*dispatch)
@@ -48,19 +49,49 @@ 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
 
56
57
  private
57
58
  attr_reader :slot, :configuration, :control, :log
58
59
 
60
+ # **The handler carries nothing, and that is the whole point.**
61
+ #
62
+ # RLIMIT_FSIZE is enforced by SIGXFSZ, and the supervisor used to read that signal off a wait status
63
+ # and answer `fsize`, permanently, against whatever input the worker was holding. Workers share a uid,
64
+ # so a sibling sends SIGXFSZ as easily as the kernel does and a wait status cannot tell them apart —
65
+ # nor can a handler, because Ruby hands one only the signal number and no siginfo, so SI_KERNEL and
66
+ # SI_USER are not reachable from here.
67
+ #
68
+ # Catching it makes the kernel fail the offending write with EFBIG rather than killing the process,
69
+ # and that error return is what a signal is not: it is raised by a write this process made, and no
70
+ # signal any sibling sends produces one. So the verdict below keys on Errno::EFBIG and this handler
71
+ # does nothing at all. A handler that set so much as a flag the verdict consulted would hand the
72
+ # forgery straight back.
73
+ #
74
+ # EFBIG is evidence of this request's own write and not proof of which limit stopped it. A filesystem
75
+ # maximum, or the caller's own file, answers the same errno — so this says the bytes did not go, by
76
+ # something this request did, rather than naming RLIMIT_FSIZE. Narrowing it further means checking the
77
+ # written file against the effective limit at each write site, which is not what this changes.
78
+ #
79
+ # A block rather than "IGNORE", because an ignored disposition survives execve and a handled one does
80
+ # not. A tool must keep dying on its own RLIMIT_FSIZE rather than writing past it, and a tool that
81
+ # died by signal is `crashed` and transient — its wait status is no more trustworthy than a worker's,
82
+ # since a sibling can signal a tool too.
83
+ def disarm_file_size_signal
84
+ Signal.trap("XFSZ") { nil }
85
+ end
86
+
59
87
  # Returns [connection, queued_ms], or nil once the supervisor has retired this worker by closing the
60
88
  # control socket. The connection arrives as a descriptor: the supervisor accepted it and never
61
89
  # called recvmsg, so the caller's own descriptors are still queued on it and this worker's recvmsg
62
90
  # is what installs them.
63
91
  def await_dispatch
92
+ # Not in `serve`'s ensure: `worker.crashed` is written from `run`, past it.
93
+ @op = nil
94
+
64
95
  line, descriptors = control.receive_message(limit: DISPATCH_BYTES)
65
96
  return nil if line.nil?
66
97
 
@@ -75,12 +106,17 @@ module HotCell
75
106
  received = []
76
107
  response = nil
77
108
 
78
- # Before the request rather than at boot, and one directory rather than two. A tool reads its
79
- # configuration from $HOME and that configuration is executable, so a home that outlived the request
80
- # let one compromised conversion reconfigure every later one on this slot. adr/0003.
81
- slot.make_home
82
-
83
109
  begin
110
+ # Before the request rather than at boot, and one directory rather than two. A tool reads its
111
+ # configuration from $HOME and that configuration is executable, so a home that outlived the
112
+ # request let one compromised conversion reconfigure every later one on this slot. adr/0003.
113
+ #
114
+ # Inside the begin, because a home that cannot be created is a broken deployment and answers
115
+ # `failed`, which is transient. Outside it the raise skipped every response path and reached
116
+ # `run`, which exits the worker — after this ensure had already reported idle `"ok"`, counting a
117
+ # success for a request that never ran.
118
+ ENV["HOME"] = slot.make_home
119
+
84
120
  line, received = connection.receive_message
85
121
  response = if line.nil?
86
122
  # The caller closed before sending a request. Transient, so it is never written against a blob,
@@ -98,6 +134,10 @@ module HotCell
98
134
  # transient. Adding it back would condemn a blob for the cell's own bad moment.
99
135
  rescue NoMemoryError, MemoryExhausted => error
100
136
  response = refuse(Codes::KILLED, error, timing, cause: Codes::MEMORY)
137
+ # The one place a file-size verdict can be earned. EFBIG comes back from a write this worker made
138
+ # past its own RLIMIT_FSIZE, so unlike the signal it cannot arrive from anywhere else.
139
+ rescue Errno::EFBIG => error
140
+ response = refuse(Codes::KILLED, error, timing, cause: Codes::FSIZE)
101
141
  rescue StandardError => error
102
142
  response = refuse("failed", error, timing)
103
143
  end
@@ -107,6 +147,7 @@ module HotCell
107
147
  ensure
108
148
  received.each(&:close)
109
149
  connection.close
150
+ home = slot.home
110
151
  swept = slot.remove_home
111
152
 
112
153
  # After the answer and before reporting idle, which is the only window where this costs nobody. How
@@ -115,21 +156,29 @@ module HotCell
115
156
  # staging, where it would spend the next request's deadline on the previous request's mess. Here the
116
157
  # caller already has its response, and `report_idle` is what makes this worker available — so the
117
158
  # supervisor will not dispatch into a worker that is still sweeping.
118
- slot.sweep
119
- report_uncleaned unless swept
120
- report_idle response&.failure&.code
159
+ report_uncleaned home unless swept
160
+ report_unswept unless slot.sweep
161
+ report_idle response&.failure
121
162
  end
122
163
 
123
164
  # A removal that failed is the one thing here nobody else can see. The bytes stay on the shared tmpfs
124
165
  # after the caller has been told the request is over, and a sibling worker can cause it by writing into
125
166
  # the tree while remove_entry walks it. It cannot raise from an ensure, so it says so instead. One line
126
167
  # per request, from the ensure, because that is the attempt that knows the final state.
127
- def report_uncleaned
128
- log.write "slot.uncleaned", pid: Process.pid, slot: slot.number, home: slot.home
168
+ def report_uncleaned(home)
169
+ log.write "slot.uncleaned", pid: Process.pid, slot: slot.number, home: home
170
+ end
171
+
172
+ # The other half of the same fact. A sweep removes what the supervisor renamed out of the way after a
173
+ # killed request, and its failure was the one cleanup outcome nobody said anything about — so a slot
174
+ # accumulating one tree per request looked exactly like a slot that was clean.
175
+ def report_unswept
176
+ log.write "slot.unswept", pid: Process.pid, slot: slot.number, home: slot.directory
129
177
  end
130
178
 
131
179
  def handle(line, received, timing)
132
180
  request = Request.parse(line)
181
+ @op = request.op
133
182
 
134
183
  unless request.current_version?
135
184
  return refuse("protocol", request.version_mismatch, timing)
@@ -220,12 +269,38 @@ module HotCell
220
269
  # The supervisor enforces the deadline and never reads a request, so it cannot know that this
221
270
  # operation asked for less than the cell's maximum. The worker is the only thing that knows, and it
222
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`.
223
274
  def report_deadline(operation)
224
- 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
225
288
  end
226
289
 
227
- def report_idle(code)
228
- tell idle: true, code: code || "ok"
290
+ # The cause travels with the code so the supervisor can still count a kill by cause. It used to read
291
+ # that off a wait status; it reads the worker's own report now, and only when there is a cause to send,
292
+ # so an ordinary idle report is the two keys it always was.
293
+ #
294
+ # **This is a metric and not a verdict.** The verdict went to the caller on the work connection before
295
+ # this line runs. A compromised worker can report a cause its request never had, or withhold one it
296
+ # did, so `killed_by` is what workers said rather than what happened — which is why the supervisor
297
+ # checks the value against the known causes before interning it, and why nothing downstream may treat
298
+ # it as evidence about a blob.
299
+ def report_idle(failure)
300
+ return tell(idle: true, code: "ok") if failure.nil?
301
+ return tell(idle: true, code: failure.code) if failure.cause.nil?
302
+
303
+ tell idle: true, code: failure.code, cause: failure.cause
229
304
  end
230
305
 
231
306
  def tell(**message)
@@ -243,7 +318,7 @@ module HotCell
243
318
 
244
319
  connection.write_line line_for(response)
245
320
  rescue SystemCallError, IOError
246
- log.write "request.abandoned", pid: Process.pid, slot: slot.number
321
+ log.write "request.abandoned", pid: Process.pid, slot: slot.number, op: @op
247
322
  end
248
323
 
249
324
  def line_for(response)
@@ -255,7 +330,8 @@ module HotCell
255
330
  def record(response, timing)
256
331
  return if response.nil?
257
332
 
258
- 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",
259
335
  permanent: response.failure&.permanent?,
260
336
  outcome: response.failure ? "failure" : "success",
261
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
@@ -15,22 +15,18 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.0
18
+ version: 0.3.0
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.1.0
25
+ version: 0.3.0
26
26
  description: |
27
- The cell side of HotCell. A supervisor listens on two Unix sockets, forks a worker per request,
28
- holds each worker to a wall-clock deadline and a set of resource limits, and answers for one that
29
- dies without reporting.
30
-
31
- An operation subclasses HotCell::Operation, declares its limits, and implements perform. This gem
32
- deliberately depends on no application framework: nothing about it loads ActiveSupport, because a
33
- sandbox should carry only what the conversion needs.
27
+ Runs a HotCell container. A supervisor listens on two Unix sockets, forks a worker for each request,
28
+ and enforces a wall clock deadline and resource limits on it. Write the work as a subclass of
29
+ HotCell::Operation.
34
30
  email:
35
31
  - mike@37signals.com
36
32
  executables:
@@ -65,8 +61,8 @@ licenses:
65
61
  - MIT
66
62
  metadata:
67
63
  homepage_uri: https://github.com/basecamp/hotcell
68
- source_code_uri: https://github.com/basecamp/hotcell/tree/v0.1.0/hotcell-server
69
- changelog_uri: https://github.com/basecamp/hotcell/blob/v0.1.0/CHANGELOG.md
64
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.3.0/hotcell-server
65
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.3.0/CHANGELOG.md
70
66
  bug_tracker_uri: https://github.com/basecamp/hotcell/issues
71
67
  rubygems_mfa_required: 'true'
72
68
  rdoc_options: []