hotcell-server 0.0.0 → 0.1.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
+ # 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
+
119
+ SOCKETS = [ "work.sock", "control.sock" ].freeze
120
+
121
+ # Request memory is protected by kernel.yama.ptrace_scope >= 1, and nothing else protects it. That is a
122
+ # host sysctl no container flag can supply.
123
+ PTRACE_SCOPE = "/proc/sys/kernel/yama/ptrace_scope"
124
+
125
+ # A control connection that has not sent its request yet. Reading it non-blockingly is what stops a
126
+ # client that connects and then says nothing from stalling the loop every conversion depends on.
127
+ Pending = Struct.new(:connection, :accepted_at, :buffer)
128
+
129
+ # Only to bound the list. The channel's whole value is answering when nothing else does, so this is set
130
+ # far above any real scrape rate rather than as a throttle.
131
+ CONTROL_BACKLOG = 64
132
+
133
+ attr_reader :configuration, :counters, :log, :directory, :workspace
134
+
135
+ def initialize(directory:, workspace: nil, configuration: HotCell.configuration, log: Log.new,
136
+ ptrace_scope_path: PTRACE_SCOPE)
137
+ @directory = directory
138
+ @workspace = workspace || File.join(Dir.tmpdir, "hotcell-workspace")
139
+ @configuration = configuration
140
+ @log = log
141
+ @ptrace_scope_path = ptrace_scope_path
142
+ @children = {}
143
+ @queue = []
144
+ @control_pending = []
145
+ @counters = Counters.new
146
+ @stopping = false
147
+ end
148
+
149
+ def boot
150
+ verify_socket_paths!
151
+ verify_limits!
152
+ verify_ptrace_scope!
153
+ prepare_directories
154
+ preload
155
+ @work = listen "work.sock"
156
+ @control = listen "control.sock"
157
+ @control_handler = Control.new(configuration: configuration, counters: counters)
158
+ trap_signals
159
+
160
+ log.write "cell.boot", pid: Process.pid, directory: directory, operations: Registry.names,
161
+ configuration: configuration.to_h
162
+ self
163
+ end
164
+
165
+ def run
166
+ until stopped?
167
+ readable, = IO.select(sources, nil, nil, wait_for)
168
+ Array(readable).each { |source| handle source }
169
+
170
+ enforce_deadlines
171
+ enforce_retirements
172
+ expire_queue
173
+ expire_control
174
+ retire_idle if @stopping
175
+ pump
176
+ end
177
+ ensure
178
+ shutdown
179
+ end
180
+
181
+ private
182
+ # SIGTERM is the only way in. drain_signals owns the transition because the log line and the queue
183
+ # refusal are what it means; a setter that skipped them would be a trap rather than an affordance.
184
+ def stopped?
185
+ @stopping && @children.empty?
186
+ end
187
+
188
+ def sources
189
+ [ @signals ].tap do |list|
190
+ list.concat @children.each_value.map { |child| child.control.socket }.reject(&:closed?)
191
+ list.concat @control_pending.map { |pending| pending.connection.socket }.reject(&:closed?)
192
+ list.push @work, @control unless @stopping
193
+ end
194
+ end
195
+
196
+ # The nearest thing that needs doing without anybody knocking: a deadline, a queued connection that
197
+ # has waited long enough to be told so, or a control client that never said what it wanted.
198
+ def wait_for
199
+ now = Clock.now
200
+ nearest = [ *@children.each_value.filter_map(&:expires_at),
201
+ *@children.each_value.filter_map { |child| child.lingers_until(Configuration::KILL_GRACE) },
202
+ *@queue.map { |(_, queued_at)| queued_at + configuration.queue_wait },
203
+ *@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline } ].min
204
+ return nil if nearest.nil?
205
+
206
+ [ nearest - now, 0 ].max
207
+ end
208
+
209
+ def handle(source)
210
+ case source
211
+ when @signals then drain_signals
212
+ when @work then accept_work
213
+ when @control then accept_control
214
+ else
215
+ pending = pending_control(source)
216
+ pending ? read_control(pending) : child_reported(source)
217
+ end
218
+ end
219
+
220
+ # Reap on SIGCHLD, not at the top of the accept loop. A worker killed by a resource limit cannot
221
+ # report its own death, so the supervisor reports it — and a supervisor that only reaps when the next
222
+ # connection arrives never reports it on an idle cell, leaving the caller to wait out its whole
223
+ # timeout for a worker that died in the first second.
224
+ #
225
+ # **Accepted risk.** Handling TERM is also how a worker stops the whole cell. Workers share this
226
+ # process's uid, so they may signal it; the kernel protects a namespace's pid 1 from signals it has no
227
+ # handler for, and this installs one. A worker can SIGKILL its siblings for the same reason, which
228
+ # `Codes` already relies on when it refuses to attribute a signal to the input a worker was holding.
229
+ # The premise is that handling TERM is not optional — it is how an orchestrator stops a cell without
230
+ # killing requests in flight — and that denial of service against a cell is out of scope per
231
+ # docs/DESIGN.md.
232
+ def trap_signals
233
+ @signals, @signal_writer = IO.pipe
234
+ trap("CHLD") { @signal_writer.write_nonblock "C", exception: false }
235
+ [ "INT", "TERM" ].each do |signal|
236
+ trap(signal) { @signal_writer.write_nonblock "S", exception: false }
237
+ end
238
+ end
239
+
240
+ def drain_signals
241
+ bytes = @signals.read_nonblock(256, exception: false)
242
+ return if bytes.nil? || bytes == :wait_readable
243
+
244
+ if bytes.include?("S") && !@stopping
245
+ @stopping = true
246
+ log.write "cell.stopping", pid: Process.pid, running: running, queued: @queue.size
247
+ refuse_queue "the cell is stopping"
248
+ end
249
+
250
+ reap
251
+ end
252
+
253
+ # `sources` drops the listeners once `@stopping` is set, but a listener already readable in the same
254
+ # IO.select batch as the stop signal is still handled this pass. Refusing here rather than admitting a
255
+ # connection the stopping cell will never pump is what closes that one-batch window. The connection is
256
+ # left in the backlog and reset when the listener closes at shutdown, which the caller reads as transient.
257
+ def accept_work
258
+ return if @stopping
259
+
260
+ socket = @work.accept_nonblock(exception: false)
261
+ return if socket == :wait_readable
262
+
263
+ connection = Connection.new(socket)
264
+
265
+ if admit?(@queue.size)
266
+ @queue << [ connection, Clock.now ]
267
+ counters.observe_queue @queue.size
268
+ else
269
+ refuse connection, "the queue is full at #{@queue.size}"
270
+ end
271
+ end
272
+
273
+ def accept_control
274
+ return if @stopping
275
+
276
+ socket = @control.accept_nonblock(exception: false)
277
+ return if socket == :wait_readable
278
+
279
+ connection = Connection.new(socket)
280
+
281
+ if @control_pending.size >= CONTROL_BACKLOG
282
+ answer connection, Failure.new(code: "capacity", message: "#{CONTROL_BACKLOG} control connections are already waiting")
283
+ else
284
+ @control_pending << Pending.new(connection, Clock.now, "".b)
285
+ end
286
+ end
287
+
288
+ # Everything accepted and not yet answered, against everything this cell can hold. `running <
289
+ # concurrency ||` used to short-circuit this, which sounds like a fast path and is a hole: when fork
290
+ # fails with EAGAIN nothing runs, `running` stays 0, the left side is always true, and the queue grows
291
+ # without bound — under exactly the host pressure the fork rescue exists to survive, until this process
292
+ # runs out of descriptors in an accept nobody rescues.
293
+ def admit?(queued)
294
+ running + queued < configuration.concurrency + configuration.queue_size
295
+ end
296
+
297
+ def pending_control(socket)
298
+ @control_pending.find { |pending| pending.connection.socket == socket }
299
+ end
300
+
301
+ # Keep whatever arrived and come back for the rest. A stream socket does not promise the whole line
302
+ # lands in one read, and a blocking read here would put the loop at the mercy of a control client.
303
+ def read_control(pending)
304
+ chunk = pending.connection.socket.read_nonblock(MAX_REQUEST_BYTES, exception: false)
305
+ return if chunk == :wait_readable
306
+
307
+ return drop_control(pending) if chunk.nil?
308
+
309
+ pending.buffer << chunk
310
+
311
+ # Size before newline, so a complete line over the limit is refused rather than parsed. Checking the
312
+ # newline first handed an oversized-but-terminated message to the parser whenever its newline arrived
313
+ # in a later read, which is the limit the read is here to enforce.
314
+ if pending.buffer.bytesize > MAX_REQUEST_BYTES
315
+ @control_pending.delete pending
316
+ answer pending.connection,
317
+ Failure.new(code: "invalid", message: "control message over #{MAX_REQUEST_BYTES} bytes")
318
+ elsif pending.buffer.include?("\n")
319
+ @control_pending.delete pending
320
+ answer_control pending.connection, pending.buffer.force_encoding(Encoding::UTF_8)
321
+ end
322
+ end
323
+
324
+ # A control answer that cannot be serialized must not take the cell down with it. This channel exists to
325
+ # be available when nothing else is, and it runs inside the loop every conversion depends on — so
326
+ # anything raised while answering a scrape would stop the cell serving.
327
+ #
328
+ # **No test, because nothing can currently raise here.** `max_requests_per_worker: :unlimited` used to: a
329
+ # Symbol is not JSON-native, so a cell configured that way could not describe itself. Configuration#to_h
330
+ # reports it as a String now, and nothing else describe or metrics returns is unserializable. The rescue stays
331
+ # for the next field added to either — one unserializable value would otherwise stop the cell serving
332
+ # conversions, and this channel exists to answer when nothing else does.
333
+ def answer_control(connection, line)
334
+ response = begin
335
+ @control_handler.answer(line, running: running, queued: @queue.size).to_line
336
+ rescue StandardError => error
337
+ log.write "control.unanswerable", error: error.class.name, message: Failure.sanitize(error.message)
338
+ Response.failed(Failure.new(code: "failed", error_class: error.class.name,
339
+ message: error.message)).to_line
340
+ end
341
+
342
+ connection.write_line response
343
+ rescue SystemCallError, IOError
344
+ counters.cancelled!
345
+ ensure
346
+ connection.close
347
+ end
348
+
349
+ def expire_control
350
+ return if @control_pending.empty?
351
+
352
+ now = Clock.now
353
+ @control_pending.reject! do |pending|
354
+ next false if now - pending.accepted_at < configuration.control_deadline
355
+
356
+ log.write "control.abandoned", waited_s: configuration.control_deadline
357
+ pending.connection.close
358
+ true
359
+ end
360
+ end
361
+
362
+ def drop_control(pending)
363
+ @control_pending.delete pending
364
+ pending.connection.close
365
+ end
366
+
367
+ def pump
368
+ return if @stopping
369
+
370
+ while @queue.any? && (child = available_child)
371
+ connection, queued_at = @queue.shift
372
+ break unless dispatch child, connection, Clock.ms_since(queued_at)
373
+ end
374
+ end
375
+
376
+ # A worker can die between the fork and this write. Answering rather than raising is what keeps one dead
377
+ # worker from taking the whole cell down with it, and the caller gets a transient verdict either way.
378
+ def dispatch(child, connection, queued_ms)
379
+ child.dispatched connection, configuration.limits.deadline, at: Clock.now
380
+
381
+ child.control.send_message JSON.generate({ queued_ms: queued_ms }) << "\n",
382
+ descriptors: [ connection ]
383
+ true
384
+ rescue SystemCallError, IOError => error
385
+ log.write "worker.undispatchable", pid: child.pid, slot: child.slot.number,
386
+ error: error.class.name
387
+
388
+ # Released rather than finished: this is the only path that hands the connection back to be answered
389
+ # here, so the client connection must not be closed on the way out. `retire` closes the worker's
390
+ # control socket, which is a different socket — without it the worker stays blocked in await_dispatch,
391
+ # never frees its slot, and shutdown waits on it forever.
392
+ child.released
393
+ retire child
394
+ answer connection, Failure.new(code: Codes::KILLED, cause: Codes::CRASHED, message: error.message)
395
+ false
396
+ end
397
+
398
+ def available_child
399
+ @children.each_value.find(&:available?) || spawn
400
+ end
401
+
402
+ def spawn
403
+ number = free_slot or return nil
404
+ slot = Slot.build(workspace, number)
405
+ supervisor_side, worker_side = UNIXSocket.pair(:STREAM)
406
+
407
+ # A fork that fails is a host under pressure, not a reason to stop serving. The request stays queued
408
+ # and is either dispatched on a later pass or answered `capacity` when its wait runs out.
409
+ pid = begin
410
+ fork do
411
+ become_worker supervisor_side
412
+ Worker.new(slot: slot, configuration: configuration, control: Connection.new(worker_side),
413
+ log: log).run
414
+ end
415
+ rescue SystemCallError => error
416
+ log.write "worker.unforkable", slot: number, error: error.class.name, message: error.message
417
+ supervisor_side.close
418
+ worker_side.close
419
+ return nil
420
+ end
421
+
422
+ worker_side.close
423
+ log.write "worker.forked", pid: pid, slot: number
424
+
425
+ @children[number] = Child.build(slot: slot, pid: pid, control: Connection.new(supervisor_side),
426
+ deadline: configuration.limits.deadline)
427
+ end
428
+
429
+ # Everything the supervisor holds and the worker must not: the listener, the signal pipe, the other
430
+ # children's control sockets, and every connection the supervisor is still holding for somebody else.
431
+ # The connection this worker is about to serve arrives over SCM_RIGHTS a moment from now, so closing
432
+ # the inherited copy here costs nothing and stops it lingering for the worker's whole life.
433
+ def become_worker(supervisor_side)
434
+ [ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
435
+
436
+ # Its own process group, so the deadline reaches the tools this request started rather than only the
437
+ # Ruby process that started them. A tool is a grandchild — the worker spawns it — and killing the
438
+ # worker alone left it running, reparented to this supervisor as pid 1, with no deadline, no slot
439
+ # and nothing watching it. A document that hangs ffmpeg would have accumulated one orphan per
440
+ # request until the cgroup ended the cell.
441
+ #
442
+ # **Accepted risk.** A process group is voluntary, so it holds for a tool that behaves and not for
443
+ # one that does not. Code running in a worker's child can call `setsid` and leave, which needs no
444
+ # capability and so survives `cap-drop ALL`, and neither the deadline kill nor the reap sweep reaches
445
+ # it afterwards. It then runs untimed until `pids-limit` or the cgroup ends it.
446
+ #
447
+ # It reaches further than that, so this is not the denial of service docs/DESIGN.md puts out of
448
+ # scope. A stolen listener needs a live process to hold it, and this escape is the only way one
449
+ # outlives the reap — so it is what turns the socket theft under "Worker isolation" from a dead
450
+ # socket path into a cell that intercepts every later request.
451
+ #
452
+ # The premise is that nothing in this process prevents it. A process group is the only bound the
453
+ # supervisor can impose, and every stronger one needs a capability `cap-drop ALL` removes, for the
454
+ # reasons that section records. Landlock is the candidate that fits, tracked at basecamp/hotcell#13.
455
+ Process.setpgid 0, 0
456
+
457
+ supervisor_side.close
458
+ @signals.close
459
+ @signal_writer.close
460
+ @work.close
461
+ @control.close
462
+
463
+ @children.each_value do |child|
464
+ child.control.close
465
+ child.connection&.close
466
+ end
467
+ @queue.each { |(connection, _)| connection.close }
468
+ @control_pending.each { |pending| pending.connection.close }
469
+ end
470
+
471
+ # Buffered and non-blocking, for the same reason read_control is, and more so: readability means a byte
472
+ # arrived rather than a line, and the peer here is the one process in this design that runs untrusted
473
+ # code. A blocking read would let a worker that writes half a report and then stops park the very loop
474
+ # that enforces its deadline. Draining every complete line rather than the first also means a read that
475
+ # carries two reports cannot strand the second in this process's own IO buffer, where the kernel buffer
476
+ # is empty and select will never fire again.
477
+ #
478
+ # **No test, because neither hazard can be triggered as the code stands.** A report is one small write,
479
+ # well under PIPE_BUF, so a worker cannot write half of one; and 160 requests across four concurrent
480
+ # callers at max_requests_per_worker 8 never coalesced two reports into a single read. Both defences are here
481
+ # for the change that makes them reachable — a longer report, or a worker that writes in more than one call —
482
+ # after which a blocking read parks the loop enforcing every deadline, and a stranded second report leaves the
483
+ # supervisor waiting on a worker that already answered. Both kill the cell, and both cost about four lines to
484
+ # prevent.
485
+ def child_reported(socket)
486
+ child = @children.each_value.find { |candidate| candidate.control.socket == socket }
487
+ return if child.nil?
488
+
489
+ # `exception: false` maps a would-block and end of stream to values, and nothing else: a worker
490
+ # that exits with a dispatch still queued unread on this socket resets it, and the read raised
491
+ # Errno::ECONNRESET — which nothing above here rescued, so one dead worker unwound the run loop and
492
+ # ended the cell with every in-flight request. Any errno on this socket says what end of stream
493
+ # says: the worker is gone.
494
+ chunk = begin
495
+ socket.read_nonblock(Worker::DISPATCH_BYTES, exception: false)
496
+ rescue SystemCallError
497
+ nil
498
+ end
499
+ return if chunk == :wait_readable
500
+
501
+ # End of stream means the worker is gone. Retire it as well as closing, or it stays eligible for the
502
+ # next dispatch until the reap catches up.
503
+ return retire(child) if chunk.nil?
504
+
505
+ child.buffer << chunk
506
+
507
+ # A complete line over the limit is dropped rather than parsed. The trailing size check catches a
508
+ # buffer that never terminates; a line whose newline arrived in a later read is complete, so its size
509
+ # is checked here or the limit is one the parser never sees.
510
+ while (newline = child.buffer.index("\n"))
511
+ line = child.buffer.slice!(0, newline + 1).force_encoding(Encoding::UTF_8)
512
+
513
+ if line.bytesize > Worker::DISPATCH_BYTES
514
+ unreadable_report child, "report is #{line.bytesize} bytes, over the #{Worker::DISPATCH_BYTES} byte limit"
515
+ else
516
+ apply_report child, line
517
+ end
518
+ end
519
+
520
+ return if child.buffer.bytesize <= Worker::DISPATCH_BYTES
521
+
522
+ log.write "worker.unreadable_report", pid: child.pid,
523
+ message: "report passed #{Worker::DISPATCH_BYTES} bytes with no newline"
524
+ child.buffer.clear
525
+ end
526
+
527
+ # The peer here is the one process in this design that runs untrusted code, so nothing it writes may be
528
+ # taken on trust — including its shape. `Payload.parse` answers with whatever the JSON held, and a
529
+ # worker writing `[]` used to reach `message[:deadline]` as `Array#[]`, raise TypeError, and take the
530
+ # cell down, because nothing above `run` catches anything. A compromised worker had a one-line denial
531
+ # of service against every other request.
532
+ #
533
+ # The rescue below names only MessageError, and it can, because Payload.parse now answers with that
534
+ # however the JSON layer failed. Naming the exceptions here is what let the TypeError through, and
535
+ # then an EncodingError from a key holding bytes that are not valid UTF-8.
536
+ #
537
+ # `idle` is only believed from a worker that is actually serving something. A premature one used to
538
+ # clear `dispatched_at`, which is what `overdue?` reads — so a worker could answer "I am done" and buy
539
+ # itself an unbounded deadline on a request it was still holding. One from a busy worker is still
540
+ # believed, because no check here can see the difference — what it buys is bounded instead: the next
541
+ # dispatch restores a deadline, and retirement, where every worker ends, is enforced.
542
+ #
543
+ # **Accepted risk.** "Where every worker ends" is not true at `max_requests_per_worker: :unlimited`.
544
+ # `retire?` is false for every count there, so a worker that lies about being idle is neither busy nor
545
+ # retired, `wait_for` holds no timer for it, and the deadline is the only thing that could have killed
546
+ # it. A compromised worker at that setting is therefore unkillable by this cell until the container is
547
+ # replaced. The premise is that `:unlimited` already concedes the larger half of this: the worker holds
548
+ # every one of its requests in one address space, so an input that runs code reaches all of them, which
549
+ # is what adr/0001 and the deployment guide's trade-off section already say. A worker lifetime cap
550
+ # would close it and is a new setting, not a fix to this one.
551
+ def apply_report(child, line)
552
+ message = Payload.parse(line)
553
+ return unreadable_report child, "report is a #{message.class} and must be an object" unless message.is_a?(Hash)
554
+
555
+ if message[:deadline]
556
+ child.deadline = narrowed_deadline(message[:deadline])
557
+ elsif message[:idle]
558
+ return unreadable_report child, "idle report from a worker with no request" unless child.busy?
559
+
560
+ finish child, message[:code]
561
+ end
562
+ rescue MessageError => error
563
+ unreadable_report child, Failure.sanitize(error.message)
564
+ end
565
+
566
+ # The rename that takes a finished request's directory out of the way, and a line when it does not
567
+ # happen. A failure is tolerated by design — the tree is left where it is, for a later worker to sweep
568
+ # off the hot path, because the supervisor must never delete one inline. It is not tolerated silently:
569
+ # the random suffix exists because a tool running as this user can pre-create a colliding name, so a
570
+ # rename that fails is the shape of that attempt as well as of an ordinary error.
571
+ def discard(child)
572
+ return if child.slot.discard_home
573
+
574
+ log.write "slot.undiscarded", pid: child.pid, slot: child.slot.number, home: child.slot.home
575
+ end
576
+
577
+ def unreadable_report(child, message)
578
+ log.write "worker.unreadable_report", pid: child.pid, message: message
579
+ nil
580
+ end
581
+
582
+ def finish(child, code)
583
+ counters.record outcome_code(code)
584
+ child.finished
585
+ discard child
586
+
587
+ retire child if configuration.retire?(child.served)
588
+ end
589
+
590
+ # The outcome code rides an untrusted worker report and becomes both a counter key and a Symbol, so it
591
+ # is bounded to a code this cell mints before either happens. Without this a report of `code: []` raised
592
+ # NoMethodError on `to_sym` past `apply_report`'s rescue and took the cell down, and a stream of unique
593
+ # strings grew the counters without bound. An unknown code is recorded, so a misreporting worker is
594
+ # visible rather than silent, but under one fixed bucket.
595
+ def outcome_code(reported)
596
+ return reported if reported.is_a?(String) && (reported == "ok" || Codes.known?(reported))
597
+
598
+ "unknown"
599
+ end
600
+
601
+ # Closing the control socket is the retirement: the worker's next await_dispatch sees end of stream
602
+ # and exits. The slot stays taken until the process is actually gone — and `retired_at` is what
603
+ # bounds that wait, in enforce_retirements. It only ever moves forward from nil, so retiring an
604
+ # already-retired child does not buy it a fresh grace.
605
+ def retire(child)
606
+ child.retired_at ||= Clock.now
607
+ child.control.close
608
+ end
609
+
610
+ def retire_idle
611
+ @children.each_value.select(&:available?).each { |child| retire child }
612
+ end
613
+
614
+ def enforce_deadlines
615
+ now = Clock.now
616
+
617
+ @children.each_value.select { |child| child.overdue?(now) }.each do |child|
618
+ child.killed_for = Codes::DEADLINE
619
+
620
+ # Kill first, log second. Log#write is synchronous on purpose, so a container log pipe nobody is
621
+ # draining blocks it — and with the write first, a stalled pipe meant the overdue worker was never
622
+ # killed and no other request's deadline was enforced either.
623
+ kill_group child
624
+
625
+ log.write "worker.deadline", pid: child.pid, slot: child.slot.number, deadline_s: child.deadline
626
+ end
627
+ end
628
+
629
+ # The deadline cannot reach a worker that is not busy, so a retired worker that never exited was
630
+ # timed by nothing: it held its slot until it left on its own, and it held `stopped?` false forever —
631
+ # a stopping cell waited on it until the runtime force-killed the container. KILL_GRACE is the
632
+ # budget, because a retired worker's next act is exiting, and one still here after that is not going
633
+ # to leave on its own.
634
+ def enforce_retirements
635
+ now = Clock.now
636
+
637
+ @children.each_value.select { |child| child.lingering?(now, Configuration::KILL_GRACE) }.each do |child|
638
+ # The latch rather than a verdict: a lingering child is never busy, so no caller hears this cause.
639
+ child.killed_for = Codes::CRASHED
640
+
641
+ kill_group child
642
+
643
+ log.write "worker.lingered", pid: child.pid, slot: child.slot.number, grace_s: Configuration::KILL_GRACE
644
+ end
645
+ end
646
+
647
+ # Sweeps whatever the reaped worker left in its process group — a tool it spawned outlives it on any
648
+ # death that was not the deadline kill, a crash or a SIGKILL from outside, and would then run with no
649
+ # deadline until the cgroup ended the cell. The deadline path already swept a live worker's group; this
650
+ # covers every other death.
651
+ #
652
+ # Only the group kill applies here, never `kill_group`'s fallback to the bare pid: the leader was
653
+ # reaped a few lines up, so the bare pid is either gone or, after enough churn, another process — and
654
+ # the sweep must not signal it. An empty group is the common case, so ESRCH is expected. It is safe to
655
+ # kill the group by the leader's pid even though the leader is reaped, because the supervisor is single
656
+ # threaded and mints group leaders only in `spawn`, which cannot run between the `wait2` above and here.
657
+ def sweep_group(child)
658
+ Process.kill :KILL, -child.pid
659
+ rescue Errno::ESRCH
660
+ nil
661
+ end
662
+
663
+ # The whole process group, which is the worker and everything it started. Negative pid is the group.
664
+ # Falls back to the worker alone if the group is already gone, so a worker that died between the check
665
+ # and the signal is not an error.
666
+ def kill_group(child)
667
+ Process.kill :KILL, -child.pid
668
+ rescue Errno::ESRCH
669
+ begin
670
+ Process.kill :KILL, child.pid
671
+ rescue Errno::ESRCH
672
+ nil
673
+ end
674
+ end
675
+
676
+ def expire_queue
677
+ return if @queue.empty?
678
+
679
+ now = Clock.now
680
+ @queue.reject! do |(connection, queued_at)|
681
+ next false if now - queued_at < configuration.queue_wait
682
+
683
+ refuse connection, "waited #{(now - queued_at).round(1)}s in the queue"
684
+ true
685
+ end
686
+ end
687
+
688
+ def refuse_queue(reason)
689
+ @queue.each { |(connection, _)| refuse connection, reason }
690
+ @queue.clear
691
+ end
692
+
693
+ # No recvmsg, deliberately. The caller's descriptors are still queued on this connection and the
694
+ # kernel discards them when it closes, so a refusal never installs a descriptor in this process.
695
+ def refuse(connection, reason)
696
+ counters.record "capacity"
697
+ answer connection, Failure.new(code: "capacity", message: reason), timing: { queued_ms: 0 }
698
+ end
699
+
700
+ def reap
701
+ loop do
702
+ pid, status = Process.wait2(-1, Process::WNOHANG)
703
+ break if pid.nil?
704
+
705
+ child = @children.each_value.find { |candidate| candidate.pid == pid }
706
+ next if child.nil?
707
+
708
+ # Drain whatever the worker said before it exited. Its idle report and SIGCHLD race each other, and
709
+ # the report is the only thing that knows a response was already written — without this, a worker
710
+ # that answered and exited in the same breath gets reported as a death.
711
+ while !child.control.socket.closed? && child.control.socket.wait_readable(0)
712
+ child_reported child.control.socket
713
+ end
714
+
715
+ sweep_group child
716
+ @children.delete child.slot.number
717
+ answer_for child, status
718
+ discard child
719
+ child.control.close
720
+
721
+ log.write "worker.reaped", pid: pid, slot: child.slot.number, served: child.served,
722
+ signal: signal_name(status), exit_code: status.exitstatus
723
+ end
724
+ rescue Errno::ECHILD
725
+ nil
726
+ end
727
+
728
+ # A killed worker cannot report its own death, because RLIMIT_FSIZE and the deadline KILL are
729
+ # enforced by a signal. So the supervisor holds its copy of every dispatched connection and writes
730
+ # the verdict itself. Without this the cold side sees a bare end of stream and cannot tell a limit
731
+ # breach from a crash.
732
+ # A worker still holding a connection at reap time never answered: it reports itself idle after writing,
733
+ # and that report is drained above. So this is the only thing that can answer, and whether it says the
734
+ # input did this or the cell did turns on how the worker died.
735
+ def answer_for(child, status)
736
+ return child.connection&.close unless child.busy?
737
+
738
+ cause = child.killed_for ||
739
+ SIGNAL_CAUSES.fetch(signal_name(status), 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.home,
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