hotcell-server 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/MIT-LICENSE +20 -0
- data/README.md +3 -0
- data/exe/hotcell +11 -0
- data/exe/hotcell-health +38 -0
- data/lib/hot_cell/configuration.rb +124 -0
- data/lib/hot_cell/control.rb +62 -0
- data/lib/hot_cell/counters.rb +50 -0
- data/lib/hot_cell/limits.rb +142 -0
- data/lib/hot_cell/log.rb +120 -0
- data/lib/hot_cell/operation.rb +205 -0
- data/lib/hot_cell/registry.rb +51 -0
- data/lib/hot_cell/server/errors.rb +16 -0
- data/lib/hot_cell/server/version.rb +7 -0
- data/lib/hot_cell/server.rb +45 -0
- data/lib/hot_cell/slot.rb +171 -0
- data/lib/hot_cell/supervisor.rb +884 -0
- data/lib/hot_cell/test_cell.rb +156 -0
- data/lib/hot_cell/test_operations.rb +465 -0
- data/lib/hot_cell/timing.rb +47 -0
- data/lib/hot_cell/worker.rb +324 -0
- data/lib/hotcell-server.rb +6 -0
- metadata +55 -9
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module HotCell
|
|
8
|
+
# Accepts, queues, dispatches, times, kills, reaps, and cleans up. It never evaluates image data.
|
|
9
|
+
#
|
|
10
|
+
# That last rule is mechanical rather than defensive: libvips starts its thread pool on the first
|
|
11
|
+
# evaluation and that pool does not survive fork, so a supervisor that has touched an image forks
|
|
12
|
+
# workers that deadlock forever. Reading a request line would be harmless — it is a control message from
|
|
13
|
+
# the trusted side on a bounded buffer — but the supervisor does not need to, and staying out of the
|
|
14
|
+
# request is what lets it dispatch a connection whose descriptors are still queued on it.
|
|
15
|
+
#
|
|
16
|
+
# Dispatching rather than letting workers accept is what makes the rest work. The supervisor needs to own
|
|
17
|
+
# the accept anyway, for the queue, for queued_ms, and to answer `capacity`. It also means the supervisor
|
|
18
|
+
# knows when every worker started its current request, which is what the deadline needs.
|
|
19
|
+
class Supervisor
|
|
20
|
+
# Owns "is this worker busy" and the two transitions that change the answer, because the supervisor asking
|
|
21
|
+
# `busy?` and the supervisor assigning the four fields `busy?` is computed from are the same fact. Spread
|
|
22
|
+
# across the caller, a new field is one the next transition forgets to clear.
|
|
23
|
+
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
|
+
end
|
|
28
|
+
|
|
29
|
+
def dispatched(connection, deadline, at:)
|
|
30
|
+
self.connection = connection
|
|
31
|
+
self.dispatched_at = at
|
|
32
|
+
self.deadline = deadline
|
|
33
|
+
self.killed_for = nil
|
|
34
|
+
self.served += 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Not a reset of `deadline`: the supervisor re-establishes it on the next dispatch, and leaving the last
|
|
38
|
+
# one readable is what lets a log line after the fact say what this worker was being held to.
|
|
39
|
+
def finished
|
|
40
|
+
connection&.close
|
|
41
|
+
released
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Stop being busy while leaving the connection open, for the one caller that still has to answer on it.
|
|
45
|
+
def released
|
|
46
|
+
self.connection = nil
|
|
47
|
+
self.dispatched_at = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def busy?
|
|
51
|
+
!connection.nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def available?
|
|
55
|
+
!busy? && retired_at.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A child already killed for its deadline stops being a timer, and that guard is load-bearing rather
|
|
59
|
+
# than tidy. Killing does not clear `busy?` — the supervisor still holds the connection it has to answer
|
|
60
|
+
# on — so without it `expires_at` stays in the past, `wait_for` returns 0, IO.select returns at once,
|
|
61
|
+
# and the loop re-kills and re-logs on every pass until the reap. Measured at a 0.2s deadline: 72
|
|
62
|
+
# SIGKILLs and 72 synchronous stdout writes for one breach. The window is longest exactly when the host
|
|
63
|
+
# is already struggling — a worker in uninterruptible sleep, or one tearing down gigabytes of mappings —
|
|
64
|
+
# and the loop it starves is the one enforcing every other request's deadline.
|
|
65
|
+
def overdue?(now)
|
|
66
|
+
busy? && killed_for.nil? && now - dispatched_at >= deadline
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def expires_at
|
|
70
|
+
dispatched_at + deadline if busy? && killed_for.nil?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The retirement analogue of `overdue?`, and the only timer that can reach a worker whose idle
|
|
74
|
+
# report was early: `finish` clears what `overdue?` reads, so a worker still running what it
|
|
75
|
+
# reported finished answers to this and nothing else. A retired worker has nothing left to do but
|
|
76
|
+
# exit — its closed control socket ends its await_dispatch — so the wait for it is bounded where an
|
|
77
|
+
# available worker's is not. Busy is excluded because a busy retired child is a worker that already
|
|
78
|
+
# died mid-request, which the reap answers for. `killed_for` is the same one-kill latch `overdue?`
|
|
79
|
+
# reads.
|
|
80
|
+
def lingering?(now, grace)
|
|
81
|
+
!busy? && killed_for.nil? && !retired_at.nil? && now - retired_at >= grace
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def lingers_until(grace)
|
|
85
|
+
retired_at + grace if !busy? && killed_for.nil? && !retired_at.nil?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A path longer than this fails to bind with an error that does not say so. Darwin allows four fewer
|
|
90
|
+
# bytes than Linux, and control.sock is the longer of the two names, so it overflows first.
|
|
91
|
+
SUN_PATH_MAX = RUBY_PLATFORM.include?("darwin") ? 104 : 108
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
SOCKETS = [ "work.sock", "control.sock" ].freeze
|
|
95
|
+
|
|
96
|
+
# Request memory is protected by kernel.yama.ptrace_scope >= 1, and nothing else protects it. That is a
|
|
97
|
+
# host sysctl no container flag can supply.
|
|
98
|
+
PTRACE_SCOPE = "/proc/sys/kernel/yama/ptrace_scope"
|
|
99
|
+
|
|
100
|
+
# A control connection that has not sent its request yet. Reading it non-blockingly is what stops a
|
|
101
|
+
# client that connects and then says nothing from stalling the loop every conversion depends on.
|
|
102
|
+
Pending = Struct.new(:connection, :accepted_at, :buffer)
|
|
103
|
+
|
|
104
|
+
# Only to bound the list. The channel's whole value is answering when nothing else does, so this is set
|
|
105
|
+
# far above any real scrape rate rather than as a throttle.
|
|
106
|
+
CONTROL_BACKLOG = 64
|
|
107
|
+
|
|
108
|
+
attr_reader :configuration, :counters, :log, :directory, :workspace
|
|
109
|
+
|
|
110
|
+
def initialize(directory:, workspace: nil, configuration: HotCell.configuration, log: Log.new,
|
|
111
|
+
ptrace_scope_path: PTRACE_SCOPE)
|
|
112
|
+
@directory = directory
|
|
113
|
+
@workspace = workspace || File.join(Dir.tmpdir, "hotcell-workspace")
|
|
114
|
+
@configuration = configuration
|
|
115
|
+
@log = log
|
|
116
|
+
@ptrace_scope_path = ptrace_scope_path
|
|
117
|
+
@children = {}
|
|
118
|
+
@queue = []
|
|
119
|
+
@control_pending = []
|
|
120
|
+
@counters = Counters.new
|
|
121
|
+
@stopping = false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def boot
|
|
125
|
+
verify_socket_paths!
|
|
126
|
+
verify_limits!
|
|
127
|
+
verify_ptrace_scope!
|
|
128
|
+
prepare_directories
|
|
129
|
+
preload
|
|
130
|
+
@work = listen "work.sock"
|
|
131
|
+
@control = listen "control.sock"
|
|
132
|
+
@control_handler = Control.new(configuration: configuration, counters: counters)
|
|
133
|
+
trap_signals
|
|
134
|
+
|
|
135
|
+
log.write "cell.boot", pid: Process.pid, directory: directory, operations: Registry.names,
|
|
136
|
+
configuration: configuration.to_h
|
|
137
|
+
self
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def run
|
|
141
|
+
until stopped?
|
|
142
|
+
readable, = IO.select(sources, nil, nil, wait_for)
|
|
143
|
+
Array(readable).each { |source| handle source }
|
|
144
|
+
|
|
145
|
+
enforce_deadlines
|
|
146
|
+
enforce_retirements
|
|
147
|
+
expire_queue
|
|
148
|
+
expire_control
|
|
149
|
+
retire_idle if @stopping
|
|
150
|
+
pump
|
|
151
|
+
end
|
|
152
|
+
ensure
|
|
153
|
+
shutdown
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
# SIGTERM is the only way in. drain_signals owns the transition because the log line and the queue
|
|
158
|
+
# refusal are what it means; a setter that skipped them would be a trap rather than an affordance.
|
|
159
|
+
def stopped?
|
|
160
|
+
@stopping && @children.empty?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def sources
|
|
164
|
+
[ @signals ].tap do |list|
|
|
165
|
+
list.concat @children.each_value.map { |child| child.control.socket }.reject(&:closed?)
|
|
166
|
+
list.concat @control_pending.map { |pending| pending.connection.socket }.reject(&:closed?)
|
|
167
|
+
list.push @work, @control unless @stopping
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The nearest thing that needs doing without anybody knocking: a deadline, a queued connection that
|
|
172
|
+
# has waited long enough to be told so, or a control client that never said what it wanted.
|
|
173
|
+
def wait_for
|
|
174
|
+
now = Clock.now
|
|
175
|
+
nearest = [ *@children.each_value.filter_map(&:expires_at),
|
|
176
|
+
*@children.each_value.filter_map { |child| child.lingers_until(Configuration::KILL_GRACE) },
|
|
177
|
+
*@queue.map { |(_, queued_at)| queued_at + configuration.queue_wait },
|
|
178
|
+
*@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline } ].min
|
|
179
|
+
return nil if nearest.nil?
|
|
180
|
+
|
|
181
|
+
[ nearest - now, 0 ].max
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def handle(source)
|
|
185
|
+
case source
|
|
186
|
+
when @signals then drain_signals
|
|
187
|
+
when @work then accept_work
|
|
188
|
+
when @control then accept_control
|
|
189
|
+
else
|
|
190
|
+
pending = pending_control(source)
|
|
191
|
+
pending ? read_control(pending) : child_reported(source)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Reap on SIGCHLD, not at the top of the accept loop. A worker killed by a resource limit cannot
|
|
196
|
+
# report its own death, so the supervisor reports it — and a supervisor that only reaps when the next
|
|
197
|
+
# connection arrives never reports it on an idle cell, leaving the caller to wait out its whole
|
|
198
|
+
# timeout for a worker that died in the first second.
|
|
199
|
+
#
|
|
200
|
+
# **Accepted risk.** Handling TERM is also how a worker stops the whole cell. Workers share this
|
|
201
|
+
# process's uid, so they may signal it; the kernel protects a namespace's pid 1 from signals it has no
|
|
202
|
+
# handler for, and this installs one. A worker can SIGKILL its siblings for the same reason, which
|
|
203
|
+
# `Codes` already relies on when it refuses to attribute a signal to the input a worker was holding.
|
|
204
|
+
# The premise is that handling TERM is not optional — it is how an orchestrator stops a cell without
|
|
205
|
+
# killing requests in flight — and that denial of service against a cell is out of scope per
|
|
206
|
+
# docs/DESIGN.md.
|
|
207
|
+
def trap_signals
|
|
208
|
+
@signals, @signal_writer = IO.pipe
|
|
209
|
+
trap("CHLD") { @signal_writer.write_nonblock "C", exception: false }
|
|
210
|
+
[ "INT", "TERM" ].each do |signal|
|
|
211
|
+
trap(signal) { @signal_writer.write_nonblock "S", exception: false }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def drain_signals
|
|
216
|
+
bytes = @signals.read_nonblock(256, exception: false)
|
|
217
|
+
return if bytes.nil? || bytes == :wait_readable
|
|
218
|
+
|
|
219
|
+
if bytes.include?("S") && !@stopping
|
|
220
|
+
@stopping = true
|
|
221
|
+
log.write "cell.stopping", pid: Process.pid, running: running, queued: @queue.size
|
|
222
|
+
refuse_queue "the cell is stopping"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
reap
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# `sources` drops the listeners once `@stopping` is set, but a listener already readable in the same
|
|
229
|
+
# IO.select batch as the stop signal is still handled this pass. Refusing here rather than admitting a
|
|
230
|
+
# connection the stopping cell will never pump is what closes that one-batch window. The connection is
|
|
231
|
+
# left in the backlog and reset when the listener closes at shutdown, which the caller reads as transient.
|
|
232
|
+
def accept_work
|
|
233
|
+
return if @stopping
|
|
234
|
+
|
|
235
|
+
socket = @work.accept_nonblock(exception: false)
|
|
236
|
+
return if socket == :wait_readable
|
|
237
|
+
|
|
238
|
+
connection = Connection.new(socket)
|
|
239
|
+
|
|
240
|
+
if admit?(@queue.size)
|
|
241
|
+
@queue << [ connection, Clock.now ]
|
|
242
|
+
counters.observe_queue @queue.size
|
|
243
|
+
else
|
|
244
|
+
refuse connection, "the queue is full at #{@queue.size}"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def accept_control
|
|
249
|
+
return if @stopping
|
|
250
|
+
|
|
251
|
+
socket = @control.accept_nonblock(exception: false)
|
|
252
|
+
return if socket == :wait_readable
|
|
253
|
+
|
|
254
|
+
connection = Connection.new(socket)
|
|
255
|
+
|
|
256
|
+
if @control_pending.size >= CONTROL_BACKLOG
|
|
257
|
+
answer connection, Failure.new(code: "capacity", message: "#{CONTROL_BACKLOG} control connections are already waiting")
|
|
258
|
+
else
|
|
259
|
+
@control_pending << Pending.new(connection, Clock.now, "".b)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Everything accepted and not yet answered, against everything this cell can hold. `running <
|
|
264
|
+
# concurrency ||` used to short-circuit this, which sounds like a fast path and is a hole: when fork
|
|
265
|
+
# fails with EAGAIN nothing runs, `running` stays 0, the left side is always true, and the queue grows
|
|
266
|
+
# without bound — under exactly the host pressure the fork rescue exists to survive, until this process
|
|
267
|
+
# runs out of descriptors in an accept nobody rescues.
|
|
268
|
+
def admit?(queued)
|
|
269
|
+
running + queued < configuration.concurrency + configuration.queue_size
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def pending_control(socket)
|
|
273
|
+
@control_pending.find { |pending| pending.connection.socket == socket }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Keep whatever arrived and come back for the rest. A stream socket does not promise the whole line
|
|
277
|
+
# lands in one read, and a blocking read here would put the loop at the mercy of a control client.
|
|
278
|
+
def read_control(pending)
|
|
279
|
+
chunk = pending.connection.socket.read_nonblock(MAX_REQUEST_BYTES, exception: false)
|
|
280
|
+
return if chunk == :wait_readable
|
|
281
|
+
|
|
282
|
+
return drop_control(pending) if chunk.nil?
|
|
283
|
+
|
|
284
|
+
pending.buffer << chunk
|
|
285
|
+
|
|
286
|
+
# Size before newline, so a complete line over the limit is refused rather than parsed. Checking the
|
|
287
|
+
# newline first handed an oversized-but-terminated message to the parser whenever its newline arrived
|
|
288
|
+
# in a later read, which is the limit the read is here to enforce.
|
|
289
|
+
if pending.buffer.bytesize > MAX_REQUEST_BYTES
|
|
290
|
+
@control_pending.delete pending
|
|
291
|
+
answer pending.connection,
|
|
292
|
+
Failure.new(code: "invalid", message: "control message over #{MAX_REQUEST_BYTES} bytes")
|
|
293
|
+
elsif pending.buffer.include?("\n")
|
|
294
|
+
@control_pending.delete pending
|
|
295
|
+
answer_control pending.connection, pending.buffer.force_encoding(Encoding::UTF_8)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# A control answer that cannot be serialized must not take the cell down with it. This channel exists to
|
|
300
|
+
# be available when nothing else is, and it runs inside the loop every conversion depends on — so
|
|
301
|
+
# anything raised while answering a scrape would stop the cell serving.
|
|
302
|
+
#
|
|
303
|
+
# **No test, because nothing can currently raise here.** `max_requests_per_worker: :unlimited` used to: a
|
|
304
|
+
# Symbol is not JSON-native, so a cell configured that way could not describe itself. Configuration#to_h
|
|
305
|
+
# reports it as a String now, and nothing else describe or metrics returns is unserializable. The rescue stays
|
|
306
|
+
# for the next field added to either — one unserializable value would otherwise stop the cell serving
|
|
307
|
+
# conversions, and this channel exists to answer when nothing else does.
|
|
308
|
+
def answer_control(connection, line)
|
|
309
|
+
response = begin
|
|
310
|
+
@control_handler.answer(line, running: running, queued: @queue.size).to_line
|
|
311
|
+
rescue StandardError => error
|
|
312
|
+
log.write "control.unanswerable", error: error.class.name, message: Failure.sanitize(error.message)
|
|
313
|
+
Response.failed(Failure.new(code: "failed", error_class: error.class.name,
|
|
314
|
+
message: error.message)).to_line
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
connection.write_line response
|
|
318
|
+
rescue SystemCallError, IOError
|
|
319
|
+
counters.cancelled!
|
|
320
|
+
ensure
|
|
321
|
+
connection.close
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def expire_control
|
|
325
|
+
return if @control_pending.empty?
|
|
326
|
+
|
|
327
|
+
now = Clock.now
|
|
328
|
+
@control_pending.reject! do |pending|
|
|
329
|
+
next false if now - pending.accepted_at < configuration.control_deadline
|
|
330
|
+
|
|
331
|
+
log.write "control.abandoned", waited_s: configuration.control_deadline
|
|
332
|
+
pending.connection.close
|
|
333
|
+
true
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def drop_control(pending)
|
|
338
|
+
@control_pending.delete pending
|
|
339
|
+
pending.connection.close
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def pump
|
|
343
|
+
return if @stopping
|
|
344
|
+
|
|
345
|
+
while @queue.any? && (child = available_child)
|
|
346
|
+
connection, queued_at = @queue.shift
|
|
347
|
+
break unless dispatch child, connection, Clock.ms_since(queued_at)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# A worker can die between the fork and this write. Answering rather than raising is what keeps one dead
|
|
352
|
+
# worker from taking the whole cell down with it, and the caller gets a transient verdict either way.
|
|
353
|
+
def dispatch(child, connection, queued_ms)
|
|
354
|
+
child.dispatched connection, configuration.limits.deadline, at: Clock.now
|
|
355
|
+
|
|
356
|
+
child.control.send_message JSON.generate({ queued_ms: queued_ms }) << "\n",
|
|
357
|
+
descriptors: [ connection ]
|
|
358
|
+
true
|
|
359
|
+
rescue SystemCallError, IOError => error
|
|
360
|
+
log.write "worker.undispatchable", pid: child.pid, slot: child.slot.number,
|
|
361
|
+
error: error.class.name
|
|
362
|
+
|
|
363
|
+
# Released rather than finished: this is the only path that hands the connection back to be answered
|
|
364
|
+
# here, so the client connection must not be closed on the way out. `retire` closes the worker's
|
|
365
|
+
# control socket, which is a different socket — without it the worker stays blocked in await_dispatch,
|
|
366
|
+
# never frees its slot, and shutdown waits on it forever.
|
|
367
|
+
child.released
|
|
368
|
+
retire child
|
|
369
|
+
answer connection, Failure.new(code: Codes::KILLED, cause: Codes::CRASHED, message: error.message)
|
|
370
|
+
false
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def available_child
|
|
374
|
+
@children.each_value.find(&:available?) || spawn
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def spawn
|
|
378
|
+
number = free_slot or return nil
|
|
379
|
+
slot = Slot.build(workspace, number)
|
|
380
|
+
supervisor_side, worker_side = UNIXSocket.pair(:STREAM)
|
|
381
|
+
|
|
382
|
+
# A fork that fails is a host under pressure, not a reason to stop serving. The request stays queued
|
|
383
|
+
# and is either dispatched on a later pass or answered `capacity` when its wait runs out.
|
|
384
|
+
pid = begin
|
|
385
|
+
fork do
|
|
386
|
+
become_worker supervisor_side
|
|
387
|
+
Worker.new(slot: slot, configuration: configuration, control: Connection.new(worker_side),
|
|
388
|
+
log: log).run
|
|
389
|
+
end
|
|
390
|
+
rescue SystemCallError => error
|
|
391
|
+
log.write "worker.unforkable", slot: number, error: error.class.name, message: error.message
|
|
392
|
+
supervisor_side.close
|
|
393
|
+
worker_side.close
|
|
394
|
+
return nil
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
worker_side.close
|
|
398
|
+
log.write "worker.forked", pid: pid, slot: number
|
|
399
|
+
|
|
400
|
+
@children[number] = Child.build(slot: slot, pid: pid, control: Connection.new(supervisor_side),
|
|
401
|
+
deadline: configuration.limits.deadline)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Everything the supervisor holds and the worker must not: the listener, the signal pipe, the other
|
|
405
|
+
# children's control sockets, and every connection the supervisor is still holding for somebody else.
|
|
406
|
+
# The connection this worker is about to serve arrives over SCM_RIGHTS a moment from now, so closing
|
|
407
|
+
# the inherited copy here costs nothing and stops it lingering for the worker's whole life.
|
|
408
|
+
def become_worker(supervisor_side)
|
|
409
|
+
[ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
|
|
410
|
+
|
|
411
|
+
# Its own process group, so the deadline reaches the tools this request started rather than only the
|
|
412
|
+
# Ruby process that started them. A tool is a grandchild — the worker spawns it — and killing the
|
|
413
|
+
# worker alone left it running, reparented to this supervisor as pid 1, with no deadline, no slot
|
|
414
|
+
# and nothing watching it. A document that hangs ffmpeg would have accumulated one orphan per
|
|
415
|
+
# request until the cgroup ended the cell.
|
|
416
|
+
#
|
|
417
|
+
# **Accepted risk.** A process group is voluntary, so it holds for a tool that behaves and not for
|
|
418
|
+
# one that does not. Code running in a worker's child can call `setsid` and leave, which needs no
|
|
419
|
+
# capability and so survives `cap-drop ALL`, and neither the deadline kill nor the reap sweep reaches
|
|
420
|
+
# it afterwards. It then runs untimed until `pids-limit` or the cgroup ends it.
|
|
421
|
+
#
|
|
422
|
+
# It reaches further than that, so this is not the denial of service docs/DESIGN.md puts out of
|
|
423
|
+
# scope. A stolen listener needs a live process to hold it, and this escape is the only way one
|
|
424
|
+
# outlives the reap — so it is what turns the socket theft under "Worker isolation" from a dead
|
|
425
|
+
# socket path into a cell that intercepts every later request.
|
|
426
|
+
#
|
|
427
|
+
# The premise is that nothing in this process prevents it. A process group is the only bound the
|
|
428
|
+
# supervisor can impose, and every stronger one needs a capability `cap-drop ALL` removes, for the
|
|
429
|
+
# reasons that section records. Landlock is the candidate that fits, tracked at basecamp/hotcell#13.
|
|
430
|
+
Process.setpgid 0, 0
|
|
431
|
+
|
|
432
|
+
supervisor_side.close
|
|
433
|
+
@signals.close
|
|
434
|
+
@signal_writer.close
|
|
435
|
+
@work.close
|
|
436
|
+
@control.close
|
|
437
|
+
|
|
438
|
+
@children.each_value do |child|
|
|
439
|
+
child.control.close
|
|
440
|
+
child.connection&.close
|
|
441
|
+
end
|
|
442
|
+
@queue.each { |(connection, _)| connection.close }
|
|
443
|
+
@control_pending.each { |pending| pending.connection.close }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Buffered and non-blocking, for the same reason read_control is, and more so: readability means a byte
|
|
447
|
+
# arrived rather than a line, and the peer here is the one process in this design that runs untrusted
|
|
448
|
+
# code. A blocking read would let a worker that writes half a report and then stops park the very loop
|
|
449
|
+
# that enforces its deadline. Draining every complete line rather than the first also means a read that
|
|
450
|
+
# carries two reports cannot strand the second in this process's own IO buffer, where the kernel buffer
|
|
451
|
+
# is empty and select will never fire again.
|
|
452
|
+
#
|
|
453
|
+
# **No test, because neither hazard can be triggered as the code stands.** A report is one small write,
|
|
454
|
+
# well under PIPE_BUF, so a worker cannot write half of one; and 160 requests across four concurrent
|
|
455
|
+
# callers at max_requests_per_worker 8 never coalesced two reports into a single read. Both defences are here
|
|
456
|
+
# for the change that makes them reachable — a longer report, or a worker that writes in more than one call —
|
|
457
|
+
# after which a blocking read parks the loop enforcing every deadline, and a stranded second report leaves the
|
|
458
|
+
# supervisor waiting on a worker that already answered. Both kill the cell, and both cost about four lines to
|
|
459
|
+
# prevent.
|
|
460
|
+
def child_reported(socket)
|
|
461
|
+
child = @children.each_value.find { |candidate| candidate.control.socket == socket }
|
|
462
|
+
return if child.nil?
|
|
463
|
+
|
|
464
|
+
# `exception: false` maps a would-block and end of stream to values, and nothing else: a worker
|
|
465
|
+
# that exits with a dispatch still queued unread on this socket resets it, and the read raised
|
|
466
|
+
# Errno::ECONNRESET — which nothing above here rescued, so one dead worker unwound the run loop and
|
|
467
|
+
# ended the cell with every in-flight request. Any errno on this socket says what end of stream
|
|
468
|
+
# says: the worker is gone.
|
|
469
|
+
chunk = begin
|
|
470
|
+
socket.read_nonblock(Worker::DISPATCH_BYTES, exception: false)
|
|
471
|
+
rescue SystemCallError
|
|
472
|
+
nil
|
|
473
|
+
end
|
|
474
|
+
return if chunk == :wait_readable
|
|
475
|
+
|
|
476
|
+
# End of stream means the worker is gone. Retire it as well as closing, or it stays eligible for the
|
|
477
|
+
# next dispatch until the reap catches up.
|
|
478
|
+
return retire(child) if chunk.nil?
|
|
479
|
+
|
|
480
|
+
child.buffer << chunk
|
|
481
|
+
|
|
482
|
+
# A complete line over the limit is dropped rather than parsed. The trailing size check catches a
|
|
483
|
+
# buffer that never terminates; a line whose newline arrived in a later read is complete, so its size
|
|
484
|
+
# is checked here or the limit is one the parser never sees.
|
|
485
|
+
while (newline = child.buffer.index("\n"))
|
|
486
|
+
line = child.buffer.slice!(0, newline + 1).force_encoding(Encoding::UTF_8)
|
|
487
|
+
|
|
488
|
+
if line.bytesize > Worker::DISPATCH_BYTES
|
|
489
|
+
unreadable_report child, "report is #{line.bytesize} bytes, over the #{Worker::DISPATCH_BYTES} byte limit"
|
|
490
|
+
else
|
|
491
|
+
apply_report child, line
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
return if child.buffer.bytesize <= Worker::DISPATCH_BYTES
|
|
496
|
+
|
|
497
|
+
log.write "worker.unreadable_report", pid: child.pid,
|
|
498
|
+
message: "report passed #{Worker::DISPATCH_BYTES} bytes with no newline"
|
|
499
|
+
child.buffer.clear
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# The peer here is the one process in this design that runs untrusted code, so nothing it writes may be
|
|
503
|
+
# taken on trust — including its shape. `Payload.parse` answers with whatever the JSON held, and a
|
|
504
|
+
# worker writing `[]` used to reach `message[:deadline]` as `Array#[]`, raise TypeError, and take the
|
|
505
|
+
# cell down, because nothing above `run` catches anything. A compromised worker had a one-line denial
|
|
506
|
+
# of service against every other request.
|
|
507
|
+
#
|
|
508
|
+
# The rescue below names only MessageError, and it can, because Payload.parse now answers with that
|
|
509
|
+
# however the JSON layer failed. Naming the exceptions here is what let the TypeError through, and
|
|
510
|
+
# then an EncodingError from a key holding bytes that are not valid UTF-8.
|
|
511
|
+
#
|
|
512
|
+
# `idle` is only believed from a worker that is actually serving something. A premature one used to
|
|
513
|
+
# clear `dispatched_at`, which is what `overdue?` reads — so a worker could answer "I am done" and buy
|
|
514
|
+
# itself an unbounded deadline on a request it was still holding. One from a busy worker is still
|
|
515
|
+
# believed, because no check here can see the difference — what it buys is bounded instead: the next
|
|
516
|
+
# dispatch restores a deadline, and retirement, where every worker ends, is enforced.
|
|
517
|
+
#
|
|
518
|
+
# **Accepted risk.** "Where every worker ends" is not true at `max_requests_per_worker: :unlimited`.
|
|
519
|
+
# `retire?` is false for every count there, so a worker that lies about being idle is neither busy nor
|
|
520
|
+
# retired, `wait_for` holds no timer for it, and the deadline is the only thing that could have killed
|
|
521
|
+
# it. A compromised worker at that setting is therefore unkillable by this cell until the container is
|
|
522
|
+
# replaced. The premise is that `:unlimited` already concedes the larger half of this: the worker holds
|
|
523
|
+
# every one of its requests in one address space, so an input that runs code reaches all of them, which
|
|
524
|
+
# is what adr/0001 and the deployment guide's trade-off section already say. A worker lifetime cap
|
|
525
|
+
# would close it and is a new setting, not a fix to this one.
|
|
526
|
+
def apply_report(child, line)
|
|
527
|
+
message = Payload.parse(line)
|
|
528
|
+
return unreadable_report child, "report is a #{message.class} and must be an object" unless message.is_a?(Hash)
|
|
529
|
+
|
|
530
|
+
if message[:deadline]
|
|
531
|
+
child.deadline = narrowed_deadline(message[:deadline])
|
|
532
|
+
elsif message[:idle]
|
|
533
|
+
return unreadable_report child, "idle report from a worker with no request" unless child.busy?
|
|
534
|
+
|
|
535
|
+
finish child, message[:code], message[:cause]
|
|
536
|
+
end
|
|
537
|
+
rescue MessageError => error
|
|
538
|
+
unreadable_report child, Failure.sanitize(error.message)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# The rename that takes a finished request's directory out of the way, and a line when it does not
|
|
542
|
+
# happen. A failure is tolerated by design — the tree is left where it is, for a later worker to sweep
|
|
543
|
+
# off the hot path, because the supervisor must never delete one inline. It is not tolerated silently:
|
|
544
|
+
# the random suffix exists because a tool running as this user can pre-create a colliding name, so a
|
|
545
|
+
# rename that fails is the shape of that attempt as well as of an ordinary error.
|
|
546
|
+
# The slot directory rather than the request's home, because the supervisor does not know the home. It
|
|
547
|
+
# is named in the worker after the fork, so this copy of the slot holds nil for the whole life of the
|
|
548
|
+
# child — and logging it said `null` on every failure, which is worse than saying nothing.
|
|
549
|
+
def discard(child)
|
|
550
|
+
return if child.slot.discard_home
|
|
551
|
+
|
|
552
|
+
log.write "slot.undiscarded", pid: child.pid, slot: child.slot.number, home: child.slot.directory
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def unreadable_report(child, message)
|
|
556
|
+
log.write "worker.unreadable_report", pid: child.pid, message: message
|
|
557
|
+
nil
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def finish(child, code, cause = nil)
|
|
561
|
+
counters.record outcome_code(code)
|
|
562
|
+
counters.record_kill cause if reported_cause?(code, cause)
|
|
563
|
+
child.finished
|
|
564
|
+
discard child
|
|
565
|
+
|
|
566
|
+
retire child if configuration.retire?(child.served)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# The outcome code rides an untrusted worker report and becomes both a counter key and a Symbol, so it
|
|
570
|
+
# is bounded to a code this cell mints before either happens. Without this a report of `code: []` raised
|
|
571
|
+
# NoMethodError on `to_sym` past `apply_report`'s rescue and took the cell down, and a stream of unique
|
|
572
|
+
# strings grew the counters without bound. An unknown code is recorded, so a misreporting worker is
|
|
573
|
+
# visible rather than silent, but under one fixed bucket.
|
|
574
|
+
# A worker reports its own kill cause now, so this is a value from a process that may be compromised.
|
|
575
|
+
# It is checked against the known causes rather than passed through, because `record_kill` interns it
|
|
576
|
+
# as a symbol and an unchecked one is an unbounded symbol table keyed by whatever a tool decides.
|
|
577
|
+
def reported_cause?(code, cause)
|
|
578
|
+
outcome_code(code) == Codes::KILLED && cause.is_a?(String) &&
|
|
579
|
+
Codes::PERMANENT_BY_CAUSE.key?(cause)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def outcome_code(reported)
|
|
583
|
+
return reported if reported.is_a?(String) && (reported == "ok" || Codes.known?(reported))
|
|
584
|
+
|
|
585
|
+
"unknown"
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Closing the control socket is the retirement: the worker's next await_dispatch sees end of stream
|
|
589
|
+
# and exits. The slot stays taken until the process is actually gone — and `retired_at` is what
|
|
590
|
+
# bounds that wait, in enforce_retirements. It only ever moves forward from nil, so retiring an
|
|
591
|
+
# already-retired child does not buy it a fresh grace.
|
|
592
|
+
def retire(child)
|
|
593
|
+
child.retired_at ||= Clock.now
|
|
594
|
+
child.control.close
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def retire_idle
|
|
598
|
+
@children.each_value.select(&:available?).each { |child| retire child }
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def enforce_deadlines
|
|
602
|
+
now = Clock.now
|
|
603
|
+
|
|
604
|
+
@children.each_value.select { |child| child.overdue?(now) }.each do |child|
|
|
605
|
+
child.killed_for = Codes::DEADLINE
|
|
606
|
+
|
|
607
|
+
# Kill first, log second. Log#write is synchronous on purpose, so a container log pipe nobody is
|
|
608
|
+
# draining blocks it — and with the write first, a stalled pipe meant the overdue worker was never
|
|
609
|
+
# killed and no other request's deadline was enforced either.
|
|
610
|
+
kill_group child
|
|
611
|
+
|
|
612
|
+
log.write "worker.deadline", pid: child.pid, slot: child.slot.number, deadline_s: child.deadline
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# The deadline cannot reach a worker that is not busy, so a retired worker that never exited was
|
|
617
|
+
# timed by nothing: it held its slot until it left on its own, and it held `stopped?` false forever —
|
|
618
|
+
# a stopping cell waited on it until the runtime force-killed the container. KILL_GRACE is the
|
|
619
|
+
# budget, because a retired worker's next act is exiting, and one still here after that is not going
|
|
620
|
+
# to leave on its own.
|
|
621
|
+
def enforce_retirements
|
|
622
|
+
now = Clock.now
|
|
623
|
+
|
|
624
|
+
@children.each_value.select { |child| child.lingering?(now, Configuration::KILL_GRACE) }.each do |child|
|
|
625
|
+
# The latch rather than a verdict: a lingering child is never busy, so no caller hears this cause.
|
|
626
|
+
child.killed_for = Codes::CRASHED
|
|
627
|
+
|
|
628
|
+
kill_group child
|
|
629
|
+
|
|
630
|
+
log.write "worker.lingered", pid: child.pid, slot: child.slot.number, grace_s: Configuration::KILL_GRACE
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Sweeps whatever the reaped worker left in its process group — a tool it spawned outlives it on any
|
|
635
|
+
# death that was not the deadline kill, a crash or a SIGKILL from outside, and would then run with no
|
|
636
|
+
# deadline until the cgroup ended the cell. The deadline path already swept a live worker's group; this
|
|
637
|
+
# covers every other death.
|
|
638
|
+
#
|
|
639
|
+
# Only the group kill applies here, never `kill_group`'s fallback to the bare pid: the leader was
|
|
640
|
+
# reaped a few lines up, so the bare pid is either gone or, after enough churn, another process — and
|
|
641
|
+
# the sweep must not signal it. An empty group is the common case, so ESRCH is expected. It is safe to
|
|
642
|
+
# kill the group by the leader's pid even though the leader is reaped, because the supervisor is single
|
|
643
|
+
# threaded and mints group leaders only in `spawn`, which cannot run between the `wait2` above and here.
|
|
644
|
+
def sweep_group(child)
|
|
645
|
+
Process.kill :KILL, -child.pid
|
|
646
|
+
rescue Errno::ESRCH
|
|
647
|
+
nil
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# The whole process group, which is the worker and everything it started. Negative pid is the group.
|
|
651
|
+
# Falls back to the worker alone if the group is already gone, so a worker that died between the check
|
|
652
|
+
# and the signal is not an error.
|
|
653
|
+
def kill_group(child)
|
|
654
|
+
Process.kill :KILL, -child.pid
|
|
655
|
+
rescue Errno::ESRCH
|
|
656
|
+
begin
|
|
657
|
+
Process.kill :KILL, child.pid
|
|
658
|
+
rescue Errno::ESRCH
|
|
659
|
+
nil
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def expire_queue
|
|
664
|
+
return if @queue.empty?
|
|
665
|
+
|
|
666
|
+
now = Clock.now
|
|
667
|
+
@queue.reject! do |(connection, queued_at)|
|
|
668
|
+
next false if now - queued_at < configuration.queue_wait
|
|
669
|
+
|
|
670
|
+
refuse connection, "waited #{(now - queued_at).round(1)}s in the queue"
|
|
671
|
+
true
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def refuse_queue(reason)
|
|
676
|
+
@queue.each { |(connection, _)| refuse connection, reason }
|
|
677
|
+
@queue.clear
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
# No recvmsg, deliberately. The caller's descriptors are still queued on this connection and the
|
|
681
|
+
# kernel discards them when it closes, so a refusal never installs a descriptor in this process.
|
|
682
|
+
def refuse(connection, reason)
|
|
683
|
+
counters.record "capacity"
|
|
684
|
+
answer connection, Failure.new(code: "capacity", message: reason), timing: { queued_ms: 0 }
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def reap
|
|
688
|
+
loop do
|
|
689
|
+
pid, status = Process.wait2(-1, Process::WNOHANG)
|
|
690
|
+
break if pid.nil?
|
|
691
|
+
|
|
692
|
+
child = @children.each_value.find { |candidate| candidate.pid == pid }
|
|
693
|
+
next if child.nil?
|
|
694
|
+
|
|
695
|
+
# Drain whatever the worker said before it exited. Its idle report and SIGCHLD race each other, and
|
|
696
|
+
# the report is the only thing that knows a response was already written — without this, a worker
|
|
697
|
+
# that answered and exited in the same breath gets reported as a death.
|
|
698
|
+
while !child.control.socket.closed? && child.control.socket.wait_readable(0)
|
|
699
|
+
child_reported child.control.socket
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
sweep_group child
|
|
703
|
+
@children.delete child.slot.number
|
|
704
|
+
answer_for child, status
|
|
705
|
+
discard child
|
|
706
|
+
child.control.close
|
|
707
|
+
|
|
708
|
+
log.write "worker.reaped", pid: pid, slot: child.slot.number, served: child.served,
|
|
709
|
+
signal: signal_name(status), exit_code: status.exitstatus
|
|
710
|
+
end
|
|
711
|
+
rescue Errno::ECHILD
|
|
712
|
+
nil
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# A killed worker cannot report its own death, because the deadline KILL is enforced by a signal. So
|
|
716
|
+
# the supervisor holds its copy of every dispatched connection and writes the verdict itself. Without
|
|
717
|
+
# this the cold side sees a bare end of stream and cannot tell a limit
|
|
718
|
+
# breach from a crash.
|
|
719
|
+
# A worker still holding a connection at reap time never answered: it reports itself idle after writing,
|
|
720
|
+
# and that report is drained above. So this is the only thing that can answer, and whether it says the
|
|
721
|
+
# input did this or the cell did turns on how the worker died.
|
|
722
|
+
# **The only cause this can authenticate is its own.** `killed_for` is the deadline kill this
|
|
723
|
+
# supervisor sent, and it is the one thing here that knows why a worker died. Everything else is a
|
|
724
|
+
# wait status, which says how — and workers share a uid, so any signal in one may have come from a
|
|
725
|
+
# sibling rather than from the kernel. Reading XFSZ, SEGV, ABRT or TRAP as this request's file size or
|
|
726
|
+
# memory let one compromised worker write a permanent verdict against another request's unrelated
|
|
727
|
+
# input, which Active Storage then kept. `Codes` already stated the rule this broke.
|
|
728
|
+
#
|
|
729
|
+
# The verdicts themselves are not gone, they moved to where they can be earned: the worker answers
|
|
730
|
+
# `memory` when it catches NoMemoryError and `fsize` when a write of its own returns EFBIG, on the
|
|
731
|
+
# connection it is holding. See Worker#disarm_file_size_signal.
|
|
732
|
+
#
|
|
733
|
+
# What that buys is bounded, and it is worth being exact. The supervisor is no longer an instrument
|
|
734
|
+
# for one worker to condemn another's input. A cell compromised outright still answers whatever it
|
|
735
|
+
# likes on a connection it holds, which is the socket-theft residual under "Worker isolation".
|
|
736
|
+
def answer_for(child, status)
|
|
737
|
+
return child.connection&.close unless child.busy?
|
|
738
|
+
|
|
739
|
+
cause = child.killed_for || Codes::CRASHED
|
|
740
|
+
counters.record Codes::KILLED
|
|
741
|
+
counters.record_kill cause
|
|
742
|
+
|
|
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)
|
|
745
|
+
|
|
746
|
+
answer child.connection,
|
|
747
|
+
Failure.new(code: Codes::KILLED, cause: cause, signal: signal_name(status)),
|
|
748
|
+
timing: { perform_ms: Clock.ms_since(child.dispatched_at) }
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def answer(connection, failure, timing: {})
|
|
752
|
+
connection.write_line Response.failed(failure, timing: timing).to_line
|
|
753
|
+
rescue SystemCallError, IOError
|
|
754
|
+
counters.cancelled!
|
|
755
|
+
ensure
|
|
756
|
+
connection.close
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# A worker tells the supervisor when its operation asked for less than the cell allows, because the
|
|
760
|
+
# supervisor never reads a request and cannot know. It may only ever narrow: the number the supervisor
|
|
761
|
+
# enforces is the one thing an operation must not be able to widen, and this is the side that owns
|
|
762
|
+
# invariant 6. Anything that is not a positive number is nonsense from the only process here running
|
|
763
|
+
# untrusted code, and the cell's own maximum stands.
|
|
764
|
+
def narrowed_deadline(reported)
|
|
765
|
+
return configuration.limits.deadline unless reported.is_a?(Numeric) && reported.positive?
|
|
766
|
+
|
|
767
|
+
[ reported, configuration.limits.deadline ].min
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def signal_name(status)
|
|
771
|
+
Signal.signame status.termsig if status.signaled?
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def running
|
|
775
|
+
@children.each_value.count(&:busy?)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def free_slot
|
|
779
|
+
(0...configuration.concurrency).find { |number| !@children.key?(number) }
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# A Unix socket is a filesystem object and `connect` needs write permission on it, so this mode is the
|
|
783
|
+
# access control on speaking to a cell at all.
|
|
784
|
+
#
|
|
785
|
+
# The group is what grants it. An application is already in this cell's gid so that a worker can
|
|
786
|
+
# re-open the descriptors it is handed, which every operation that gives a tool a filename needs — and
|
|
787
|
+
# nothing outside that group has business speaking to a cell. So the group carries both, and world
|
|
788
|
+
# write would give the socket away to anything else sharing either container.
|
|
789
|
+
#
|
|
790
|
+
# The group is therefore load-bearing rather than advisory: without it an application cannot connect
|
|
791
|
+
# and every call is EACCES. `HotCell.describe_cells` catches that at boot.
|
|
792
|
+
SOCKET_MODE = 0o660
|
|
793
|
+
|
|
794
|
+
def listen(name)
|
|
795
|
+
path = socket_path(name)
|
|
796
|
+
File.unlink path if File.socket?(path)
|
|
797
|
+
|
|
798
|
+
UNIXServer.new(path).tap { File.chmod SOCKET_MODE, path }
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def socket_path(name)
|
|
802
|
+
File.join directory, name
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# The socket's file mode is the access control on connecting, and the two sides do not share a uid,
|
|
806
|
+
# so 0600 by the cell is a bare EACCES at the app's first request. What actually contains this is the
|
|
807
|
+
# mount topology: the directory is a volume mounted into exactly two containers, so "anyone who can
|
|
808
|
+
# see this socket" is already "the app and the cell".
|
|
809
|
+
def verify_socket_paths!
|
|
810
|
+
SOCKETS.each do |name|
|
|
811
|
+
path = socket_path(name)
|
|
812
|
+
next if path.bytesize <= SUN_PATH_MAX
|
|
813
|
+
|
|
814
|
+
raise ConfigurationError, "#{path} is #{path.bytesize} bytes and a Unix socket path on this " \
|
|
815
|
+
"platform holds #{SUN_PATH_MAX}. Choose a shorter directory."
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
# Refuse to boot rather than warn and serve. A host sysctl is invisible to the image and it silently
|
|
820
|
+
# voids the guarantee, and a warning in a log is how a dead control stays dead. This is the one boot
|
|
821
|
+
# check that is worth having now.
|
|
822
|
+
#
|
|
823
|
+
# **Accepted risk.** An unreadable file means this is not Linux or the kernel has no Yama, which is
|
|
824
|
+
# development rather than a deployment with a broken precondition — so that warns instead. The second
|
|
825
|
+
# of those is a real hole: on a Linux kernel built without Yama the file is absent, same-uid ptrace is
|
|
826
|
+
# unrestricted, and invariant 8 is gone while this boots anyway. The premise is that the kernels this
|
|
827
|
+
# deploys on ship Yama, so refusing to boot on an absent file would fail development far more often
|
|
828
|
+
# than it would catch a broken host.
|
|
829
|
+
def verify_ptrace_scope!
|
|
830
|
+
unless File.readable?(@ptrace_scope_path)
|
|
831
|
+
return log.write "cell.ptrace_scope_unknown", path: @ptrace_scope_path,
|
|
832
|
+
message: "cannot verify that a worker is unable to read a sibling's memory"
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
scope = File.read(@ptrace_scope_path).strip
|
|
836
|
+
return unless scope == "0"
|
|
837
|
+
|
|
838
|
+
raise ConfigurationError,
|
|
839
|
+
"kernel.yama.ptrace_scope is 0 on this host, so one worker can read another request's memory " \
|
|
840
|
+
"through /proc/<pid>/mem. No container flag can set it. Set it to 1 or higher and boot again."
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def verify_limits!
|
|
844
|
+
Limits::RESOURCES.each do |key, resource|
|
|
845
|
+
wanted = configuration.limits[key]
|
|
846
|
+
next if wanted.nil?
|
|
847
|
+
|
|
848
|
+
_soft, hard = Process.getrlimit(resource)
|
|
849
|
+
next if hard == Process::RLIM_INFINITY || hard >= wanted
|
|
850
|
+
|
|
851
|
+
raise ConfigurationError, "#{key}: #{wanted} is above this process's hard limit of #{hard}, so " \
|
|
852
|
+
"a worker could not set it"
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def prepare_directories
|
|
857
|
+
FileUtils.mkdir_p directory
|
|
858
|
+
|
|
859
|
+
configuration.concurrency.times do |number|
|
|
860
|
+
slot = Slot.build(workspace, number)
|
|
861
|
+
next if slot.prepare
|
|
862
|
+
|
|
863
|
+
log.write "slot.uncleaned", slot: number, home: slot.directory,
|
|
864
|
+
message: "an earlier boot's files are still here"
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
def preload
|
|
869
|
+
Registry.operations.each { |operation| operation.before_fork.each(&:call) }
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def shutdown
|
|
874
|
+
refuse_queue "the cell is stopping"
|
|
875
|
+
@control_pending.each { |pending| pending.connection.close }
|
|
876
|
+
@children.each_value { |child| child.control.close unless child.control.socket.closed? }
|
|
877
|
+
@work&.close
|
|
878
|
+
@control&.close
|
|
879
|
+
SOCKETS.each { |name| File.unlink socket_path(name) if File.socket?(socket_path(name)) }
|
|
880
|
+
|
|
881
|
+
log.write "cell.stopped", pid: Process.pid
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
end
|