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 +4 -4
- data/lib/hot_cell/control.rb +1 -2
- data/lib/hot_cell/log.rb +5 -3
- data/lib/hot_cell/operation.rb +5 -1
- data/lib/hot_cell/server/version.rb +1 -1
- data/lib/hot_cell/slot.rb +87 -22
- data/lib/hot_cell/supervisor.rb +178 -48
- data/lib/hot_cell/test_operations.rb +113 -0
- data/lib/hot_cell/worker.rb +94 -18
- metadata +8 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96b9d4b602f5a5430605a97aec5db12ea0a67a6cdac0b1fb42e5174c2fa447a7
|
|
4
|
+
data.tar.gz: 4beed8c182fa5cfbb1bb4713ff3cb6e19772c29ac4e4dfec6d5456b217be054d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4974036dd70e168a833dbb03cf0aef47d6ce0be264ddc8327e5b4d6265c1b65d85a55767815cbf2471e6c9a534a910dcc8597bc6913cf0d1c5bec93d4d198c9d
|
|
7
|
+
data.tar.gz: 433c2c6f0e55f72b849f67a3abb9bc55129a0d5ececb9fa9b8c50c277dabee74f69d2ce95259d30c9fc0d0bbfd9b7db48cdcbd061be85677d98aaf3c76f8dd91
|
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
|
@@ -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
|
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.
|
|
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
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
110
|
+
FileUtils.chmod 0o700, directory if Dir.exist?(directory)
|
|
82
111
|
|
|
83
|
-
File.
|
|
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
|
-
|
|
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("
|
|
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
|
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
|
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
729
|
-
#
|
|
730
|
-
#
|
|
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
|
-
|
|
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
|
|
|
@@ -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.
|
|
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
|
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
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
report_idle response&.failure
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
25
|
+
version: 0.3.0
|
|
26
26
|
description: |
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
69
|
-
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.
|
|
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: []
|