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.
@@ -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