hotcell-server 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 390fc0f10953763de1f745cc3002fa0e548b362cbb21c82faba7dfa807b49dd5
4
- data.tar.gz: fd6932e090acccea7964e38d76e65ea6163b1ebf6b5f401f6854fe8fa1e38034
3
+ metadata.gz: 14fae075c3d4fef73a330453eff2c295ac7e67895d50e2b9ad73d800b50ff2f8
4
+ data.tar.gz: d3730524324b2283332bdf2f04c90923a6dc3b0ed4a8b6821b10dc46a7e1fd3a
5
5
  SHA512:
6
- metadata.gz: a34f827e6dbb2616e4e20b122ff6a8fdd78e40c19bfd8297c767c450a06ea4efaebc245d3ca3fb117caf13b70d922236e3c51f6a0a93207b5fc0a815e60e2583
7
- data.tar.gz: '0769c12d7f26f35380dd5532edd8073c160f6a823128dcc4c88fddc697fd896be8cabec1eaa21dedf3c505a26ccda5a90cc38716a23461fa70410eb92d2ccc1e'
6
+ metadata.gz: 063e11ec9aac319a707f3ca11d2d92b02d4aeeeffc35fdf2ec7e169ab8e14b82f4949b0d6f03255a4285186d43620ccb102584feaa616c02677ce6cefbee39b4
7
+ data.tar.gz: f7ca005517c6bc32c6e3b803b3c577b4e5e103bcc34b603e64534526430189b8f5b39cc87ee3235832ec4d2c078088091251fa1c66e409111ef20020d6d3e5ff
@@ -23,6 +23,7 @@ module HotCell
23
23
  queue_wait: 10, # seconds a queued connection may wait before it is answered `capacity`
24
24
  max_requests_per_worker: 1, # requests a worker serves before it is discarded
25
25
  control_deadline: 5, # seconds a control connection may take to send its request
26
+ sweep_interval: 10, # seconds between the supervisor's checks for a killed request's tree to unlink
26
27
  }.freeze
27
28
 
28
29
  LIMITS = {
@@ -51,6 +52,7 @@ module HotCell
51
52
  # in describe's JSON, and they may arrive as Active Support durations.
52
53
  @queue_wait = @queue_wait.to_f
53
54
  @control_deadline = @control_deadline.to_f
55
+ @sweep_interval = @sweep_interval.to_f
54
56
 
55
57
  # A nil is not "use the default" here, it is a missing number. A cell whose deadline is nil accepts
56
58
  # every request and then dies on the first arithmetic the supervisor does with it, so an explicit nil
@@ -103,6 +105,7 @@ module HotCell
103
105
  positive! :concurrency, integer: true
104
106
  positive! :queue_wait
105
107
  positive! :control_deadline
108
+ positive! :sweep_interval
106
109
 
107
110
  unless queue_size.is_a?(Integer) && !queue_size.negative?
108
111
  raise ConfigurationError, "queue_size: #{queue_size} must not be negative"
@@ -40,8 +40,12 @@ module HotCell
40
40
 
41
41
  # Static, and called once per registered cell at app boot. It is the cheapest way to catch a client
42
42
  # whose own timeout is below what this cell may take, and it is what `bin/hotcell describe` reads.
43
+ #
44
+ # `v` is the wire format and changes only when the format does; `server_version` says which release of
45
+ # hotcell-server is answering.
43
46
  def describe
44
- { v: PROTOCOL_VERSION, operations: Registry.names, groups: groups, **@configuration.to_h }
47
+ { v: PROTOCOL_VERSION, server_version: Server::VERSION, operations: Registry.names, groups: groups,
48
+ **@configuration.to_h }
45
49
  end
46
50
 
47
51
  private
@@ -18,8 +18,12 @@ module HotCell
18
18
  #
19
19
  # `Dir.exist?` is not the guard, because it follows symlinks and answers false for a dangling one, and
20
20
  # an entry a tool left in a directory's place is exactly what this has to remove.
21
+ #
22
+ # A tree that is gone by the time the removal fails is the outcome this wants, however it went. Two
23
+ # sweepers can meet on one discarded tree — the worker that answered on the slot and the supervisor's
24
+ # own — and the loser's walk fails on an entry the winner unlinked first.
21
25
  def self.remove_tree(path)
22
- return true unless File.exist?(path) || File.symlink?(path)
26
+ return true if gone?(path)
23
27
 
24
28
  FileUtils.remove_entry path
25
29
  true
@@ -27,12 +31,26 @@ module HotCell
27
31
  repair_and_remove path
28
32
  end
29
33
 
34
+ # `lstat` rather than `File.exist?`, which answers false for a path it cannot stat as well as for one that
35
+ # is gone. Only ENOENT means gone; a tree behind a directory a tool made unsearchable is still there.
36
+ def self.gone?(path)
37
+ File.lstat path
38
+ false
39
+ rescue Errno::ENOENT
40
+ true
41
+ rescue SystemCallError
42
+ false
43
+ end
44
+ private_class_method :gone?
45
+
30
46
  def self.repair_and_remove(path)
47
+ return true if gone?(path)
48
+
31
49
  FileUtils.chmod_R 0o700, path, force: true
32
50
  FileUtils.remove_entry path
33
51
  true
34
52
  rescue SystemCallError
35
- false
53
+ gone?(path)
36
54
  end
37
55
  private_class_method :repair_and_remove
38
56
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hot_cell/server"
4
+
5
+ # The two round trips an application runs against its own cell for a health check. Shipped in the
6
+ # gem because every deployment was copying them from examples/, and copies drift.
7
+ #
8
+ # A cell serves them only if it requires this file.
9
+ module HotCell
10
+ module Health
11
+ # The message goes in through the caller's input descriptor and out through the caller's output
12
+ # descriptor, with no copy onto scratch, so one round trip proves descriptor passing end to end.
13
+ class Echo < HotCell::Operation
14
+ operation "health.echo"
15
+
16
+ def perform(inputs, outputs)
17
+ bytes = outputs.first.to_io.write(inputs.first.to_io.read)
18
+
19
+ { bytes: bytes, staged: inputs.first.staged? }
20
+ end
21
+ end
22
+
23
+ # `fd_path` is a fresh open by name — `/dev/fd/N` on Linux, the file's own path on macOS — checked
24
+ # against this process's uid and the file's mode, so this succeeds only when the cell can open the
25
+ # caller's files by name — which echo never checks.
26
+ # It reads the input and writes the output, because a tool may need either permission.
27
+ class Reopen < HotCell::Operation
28
+ operation "health.reopen"
29
+
30
+ def perform(inputs, outputs)
31
+ source, = inputs
32
+ destination, = outputs
33
+
34
+ bytes = File.open(source.fd_path, "rb") do |input|
35
+ File.open(destination.fd_path, "wb") { |output| output.write(input.read) }
36
+ end
37
+
38
+ { bytes: bytes, staged: source.staged? || destination.staged? }
39
+ end
40
+ end
41
+ end
42
+ end
data/lib/hot_cell/log.rb CHANGED
@@ -35,11 +35,18 @@ module HotCell
35
35
  "worker.unforkable" => "ERROR",
36
36
  "worker.undispatchable" => "ERROR",
37
37
  "worker.unreadable_report" => "ERROR",
38
+ "sweeper.forked" => "INFO",
39
+ "sweeper.deadline" => "WARN",
40
+ "sweeper.unforkable" => "ERROR",
41
+ "sweeper.crashed" => "ERROR",
42
+ "scratch.swept" => "INFO",
38
43
  "control.abandoned" => "WARN",
39
44
  "control.unanswerable" => "WARN",
40
45
  "slot.uncleaned" => "WARN",
41
46
  "scratch.unswept" => "WARN",
42
47
  "slot.undiscarded" => "WARN",
48
+ "slot.unswept" => "WARN",
49
+ "sweeper.died" => "WARN",
43
50
  }.freeze
44
51
 
45
52
  def self.null
@@ -2,6 +2,6 @@
2
2
 
3
3
  module HotCell
4
4
  module Server
5
- VERSION = "0.4.1"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
@@ -15,6 +15,7 @@ require "hot_cell/timing"
15
15
  require "hot_cell/counters"
16
16
  require "hot_cell/control"
17
17
  require "hot_cell/worker"
18
+ require "hot_cell/sweeper"
18
19
  require "hot_cell/supervisor"
19
20
 
20
21
  module HotCell
data/lib/hot_cell/slot.rb CHANGED
@@ -95,7 +95,8 @@ module HotCell
95
95
  #
96
96
  # A rename within one filesystem is O(1) and takes the tree out of the way. A worker sweeps it later,
97
97
  # after it has answered and before it reports itself idle — see Worker#serve, which is the one window
98
- # where the unlinking costs nobody's latency.
98
+ # where the unlinking costs nobody's latency. A worker killed at its deadline never reaches that window,
99
+ # so the supervisor also forks a Sweeper on a timer, which unlinks in a process of its own.
99
100
  #
100
101
  # The destination carries a random suffix rather than a counter, because the tool that filled the
101
102
  # directory runs as this user and can write to the slot's directory. A predictable name lets it
@@ -133,12 +134,25 @@ module HotCell
133
134
  # Unlinks whatever discard_home renamed out of the way. Partial progress is fine: a sweep killed
134
135
  # part-way leaves fewer entries for the next one, so this converges rather than repeating.
135
136
  def sweep
136
- Dir.glob(File.join(directory, "discarded-*")).map { |path| Filesystem.remove_tree(path) }.all?
137
+ discarded.map { |path| Filesystem.remove_tree(path) }.all?
137
138
  rescue SystemCallError
138
139
  # The glob itself can fail, because the slot directory is a name a tool can replace — a symlink loop
139
140
  # in its place answers ELOOP here rather than for any one entry. This runs from the worker's ensure,
140
141
  # where a raise would replace the caller's response with a crash.
141
142
  false
142
143
  end
144
+
145
+ # What discard_home has renamed aside and nobody has unlinked yet.
146
+ def discarded
147
+ Dir.glob(File.join(directory, "discarded-*"))
148
+ end
149
+
150
+ # Streams the directory and stops at the first match, because the supervisor asks this in its loop and
151
+ # a tool can put as many entries beside the discarded ones as it likes; a glob would list and sort them all.
152
+ def discarded?
153
+ Dir.each_child(directory).any? { |name| name.start_with?("discarded-") }
154
+ rescue Errno::ENOENT
155
+ false
156
+ end
143
157
  end
144
158
  end
@@ -22,11 +22,27 @@ module HotCell
22
22
  # the accept anyway, for the queue, for queued_ms, and to answer `capacity`. It also means the supervisor
23
23
  # knows when every worker started its current request, which is what the deadline needs.
24
24
  class Supervisor
25
+ # The one deadline the supervisor enforces, on a worker and on the sweeper alike: from `started_at`,
26
+ # for `deadline` seconds, killed once. `killed_for` is the one-kill latch — a killed process stays here
27
+ # until the reap, and without the latch it would be re-killed and re-logged on every pass until then.
28
+ # See `Child#overdue?` for the measurement behind that.
29
+ module Timed
30
+ def overdue?(now)
31
+ timed? && now - started_at >= deadline
32
+ end
33
+
34
+ def expires_at
35
+ started_at + deadline if timed?
36
+ end
37
+ end
38
+
25
39
  # Owns "is this worker busy" and the two transitions that change the answer, because the supervisor asking
26
40
  # `busy?` and the supervisor assigning the four fields `busy?` is computed from are the same fact. Spread
27
41
  # across the caller, a new field is one the next transition forgets to clear.
28
42
  Child = Struct.new(:slot, :pid, :control, :connection, :dispatched_at, :deadline, :served, :killed_for,
29
43
  :op, :retired_at, :buffer, :stderr, :captured, keyword_init: true) do
44
+ include Timed
45
+
30
46
  def self.build(slot:, pid:, control:, deadline:, stderr: nil)
31
47
  new slot: slot, pid: pid, control: control, deadline: deadline, served: 0, buffer: "".b,
32
48
  stderr: stderr, captured: "".b
@@ -87,12 +103,12 @@ module HotCell
87
103
  # SIGKILLs and 72 synchronous stdout writes for one breach. The window is longest exactly when the host
88
104
  # is already struggling — a worker in uninterruptible sleep, or one tearing down gigabytes of mappings —
89
105
  # and the loop it starves is the one enforcing every other request's deadline.
90
- def overdue?(now)
91
- busy? && killed_for.nil? && now - dispatched_at >= deadline
106
+ def timed?
107
+ busy? && killed_for.nil?
92
108
  end
93
109
 
94
- def expires_at
95
- dispatched_at + deadline if busy? && killed_for.nil?
110
+ def started_at
111
+ dispatched_at
96
112
  end
97
113
 
98
114
  # The retirement analogue of `overdue?`, and the only timer that can reach a worker whose idle
@@ -111,6 +127,18 @@ module HotCell
111
127
  end
112
128
  end
113
129
 
130
+ # The one sweeper the supervisor runs at a time. Not a `Child`: it is dispatched no request, holds no
131
+ # slot and answers nobody, so none of `busy?`, retirement or the idle report applies to it. What it
132
+ # shares with a worker is the deadline, and that is `Timed` — the same measurement, the same latch, the
133
+ # same `deadline` value from the configuration, and the same kill.
134
+ Sweep = Struct.new(:pid, :started_at, :deadline, :killed_for, keyword_init: true) do
135
+ include Timed
136
+
137
+ def timed?
138
+ killed_for.nil?
139
+ end
140
+ end
141
+
114
142
  # A path longer than this fails to bind with an error that does not say so. Darwin allows four fewer
115
143
  # bytes than Linux, and control.sock is the longer of the two names, so it overflows first.
116
144
  SUN_PATH_MAX = RUBY_PLATFORM.include?("darwin") ? 104 : 108
@@ -151,6 +179,8 @@ module HotCell
151
179
  @control_pending = []
152
180
  @counters = Counters.new
153
181
  @stopping = false
182
+ @sweep = nil
183
+ @next_sweep_at = Clock.now + configuration.sweep_interval
154
184
  end
155
185
 
156
186
  def boot
@@ -161,6 +191,7 @@ module HotCell
161
191
  verify_scratches!
162
192
  prepare_directories
163
193
  preload
194
+ warm_up
164
195
  @work = listen "work.sock"
165
196
  @control = listen "control.sock"
166
197
  @control_handler = Control.new(configuration: configuration, counters: counters)
@@ -172,16 +203,20 @@ module HotCell
172
203
  end
173
204
 
174
205
  def run
206
+ prefork
207
+
175
208
  until stopped?
176
209
  readable, = IO.select(sources, nil, nil, wait_for)
177
210
  Array(readable).each { |source| handle source }
178
211
 
179
212
  enforce_deadlines
180
213
  enforce_retirements
214
+ enforce_sweep_deadline
181
215
  expire_queue
182
216
  expire_control
183
217
  retire_idle if @stopping
184
218
  pump
219
+ sweep_if_due
185
220
  end
186
221
  ensure
187
222
  shutdown
@@ -204,13 +239,16 @@ module HotCell
204
239
  end
205
240
 
206
241
  # The nearest thing that needs doing without anybody knocking: a deadline, a queued connection that
207
- # has waited long enough to be told so, or a control client that never said what it wanted.
242
+ # has waited long enough to be told so, a control client that never said what it wanted, or the
243
+ # sweeper's next tick.
208
244
  def wait_for
209
245
  now = Clock.now
210
246
  nearest = [ *@children.each_value.filter_map(&:expires_at),
211
247
  *@children.each_value.filter_map { |child| child.lingers_until(Configuration::KILL_GRACE) },
212
248
  *@queue.map { |(_, queued_at)| queued_at + configuration.queue_wait },
213
- *@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline } ].min
249
+ *@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline },
250
+ @sweep&.expires_at,
251
+ (@next_sweep_at unless @stopping) ].compact.min
214
252
  return nil if nearest.nil?
215
253
 
216
254
  [ nearest - now, 0 ].max
@@ -428,8 +466,20 @@ module HotCell
428
466
  @children.each_value.find(&:available?) || spawn
429
467
  end
430
468
 
469
+ # Forks a worker into each free slot, so that a request does not wait for `fork`. If a fork fails,
470
+ # `spawn` logs it and this stops. The next request forks its own worker.
471
+ def prefork
472
+ return if @stopping
473
+
474
+ while free_slot
475
+ break unless spawn
476
+ end
477
+ end
478
+
431
479
  def spawn
432
- number = free_slot or return nil
480
+ number = free_slot
481
+ return nil if number.nil?
482
+
433
483
  slot = Slot.build(workspace, number)
434
484
  supervisor_side, worker_side = UNIXSocket.pair(:STREAM)
435
485
  stderr_reader, stderr_writer = IO.pipe
@@ -459,12 +509,12 @@ module HotCell
459
509
  deadline: configuration.limits.deadline, stderr: stderr_reader)
460
510
  end
461
511
 
462
- # Everything the supervisor holds and the worker must not: the listener, the signal pipe, the other
463
- # children's control sockets, and every connection the supervisor is still holding for somebody else.
464
512
  # The connection this worker is about to serve arrives over SCM_RIGHTS a moment from now, so closing
465
- # the inherited copy here costs nothing and stops it lingering for the worker's whole life.
513
+ # the inherited copy in `leave_supervisor` costs nothing and stops it lingering for the worker's whole
514
+ # life.
466
515
  def become_worker(supervisor_side, stderr_reader, stderr_writer)
467
- [ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
516
+ leave_supervisor
517
+ supervisor_side.close
468
518
 
469
519
  # Its own process group, so the deadline reaches the tools this request started rather than only the
470
520
  # Ruby process that started them. A tool is a grandchild — the worker spawns it — and killing the
@@ -487,20 +537,6 @@ module HotCell
487
537
  # reasons that section records. Landlock is the candidate that fits, tracked at basecamp/hotcell#13.
488
538
  Process.setpgid 0, 0
489
539
 
490
- supervisor_side.close
491
- @signals.close
492
- @signal_writer.close
493
- @work.close
494
- @control.close
495
-
496
- @children.each_value do |child|
497
- child.control.close
498
- child.connection&.close
499
- child.stderr&.close
500
- end
501
- @queue.each { |(connection, _)| connection.close }
502
- @control_pending.each { |pending| pending.connection.close }
503
-
504
540
  # fd 2 becomes the pipe, and it stays non-blocking. `IO.pipe` already returns both ends O_NONBLOCK
505
541
  # and `reopen` is a dup2, which shares the file description — so the flag would ride along on its
506
542
  # own. It is set here anyway, because a decision this load-bearing should be in the code rather than
@@ -518,6 +554,80 @@ module HotCell
518
554
  stderr_writer.close
519
555
  end
520
556
 
557
+ # Everything the supervisor holds and a child of its must not: the listeners, the signal pipe, the
558
+ # children's control sockets, and every connection the supervisor is still holding for somebody else.
559
+ def leave_supervisor
560
+ [ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
561
+
562
+ @signals.close
563
+ @signal_writer.close
564
+ @work.close
565
+ @control.close
566
+
567
+ @children.each_value do |child|
568
+ child.control.close
569
+ child.connection&.close
570
+ child.stderr&.close
571
+ end
572
+ @queue.each { |(connection, _)| connection.close }
573
+ @control_pending.each { |pending| pending.connection.close }
574
+ end
575
+
576
+ # Ticks whether or not it forks, so a sweep that outlives an interval is left to its deadline rather
577
+ # than joined by a second one. Forking only when a slot holds a tree keeps an idle cell from paying
578
+ # for a child every interval; the glob it costs is the one `Slot#discard_home` already runs inline.
579
+ def sweep_if_due
580
+ return if @stopping || Clock.now < @next_sweep_at
581
+
582
+ @next_sweep_at = Clock.now + configuration.sweep_interval
583
+ spawn_sweeper if @sweep.nil? && discarded_anywhere?
584
+ end
585
+
586
+ # A glob that raises is a slot directory a tool replaced, and the sweeper is the process that reports
587
+ # that, so it is forked to find out.
588
+ def discarded_anywhere?
589
+ (0...configuration.concurrency).any? { |number| Slot.build(workspace, number).discarded? }
590
+ rescue SystemCallError
591
+ true
592
+ end
593
+
594
+ # Held to the cell's deadline: a tree that takes longer to unlink than a request is allowed to run is
595
+ # one an input built to, and the next tick sweeps what this one left. A fork that fails is the host
596
+ # under pressure, as in `spawn`; the trees wait for the next tick.
597
+ def spawn_sweeper
598
+ pid = fork do
599
+ leave_supervisor
600
+ Sweeper.new(workspace: workspace, configuration: configuration, log: log).run
601
+ end
602
+
603
+ log.write "sweeper.forked", pid: pid
604
+ @sweep = Sweep.new(pid: pid, started_at: Clock.now, deadline: configuration.limits.deadline)
605
+ rescue SystemCallError => error
606
+ log.write "sweeper.unforkable", error: error.class.name, message: error.message
607
+ end
608
+
609
+ # Kill first, log second, as `enforce_deadlines` does. The sweeper spawns nothing and leads no group,
610
+ # so `kill_group` reaches it through its fallback to the bare pid.
611
+ def enforce_sweep_deadline
612
+ return unless @sweep&.overdue?(Clock.now)
613
+
614
+ @sweep.killed_for = Codes::DEADLINE
615
+ kill_group @sweep
616
+ log.write "sweeper.deadline", pid: @sweep.pid, deadline_s: @sweep.deadline
617
+ end
618
+
619
+ # A sweeper this supervisor killed already has its `sweeper.deadline` line. Any other abnormal end —
620
+ # the OOM killer, a sibling's signal, a crash `Sweeper#run` could not catch — would otherwise leave
621
+ # only `sweeper.forked` behind, and look exactly like a sweep that finished.
622
+ def reap_sweeper(status)
623
+ unless @sweep.killed_for || status.success?
624
+ log.write "sweeper.died", pid: @sweep.pid, signal: signal_name(status), exit_code: status.exitstatus
625
+ end
626
+
627
+ @sweep = nil
628
+ end
629
+
630
+
521
631
  # One bounded read per pass, never a loop until the pipe is empty: this runs inside the loop that
522
632
  # enforces every request's deadline, and the peer is a worker that can print as fast as it likes.
523
633
  #
@@ -762,18 +872,23 @@ module HotCell
762
872
  # the sweep must not signal it. An empty group is the common case, so ESRCH is expected. It is safe to
763
873
  # kill the group by the leader's pid even though the leader is reaped, because the supervisor is single
764
874
  # threaded and mints group leaders only in `spawn`, which cannot run between the `wait2` above and here.
875
+ #
876
+ # macOS answers EPERM rather than ESRCH for a group whose only members are zombies, and a tool killed
877
+ # with its worker stays one until launchd reaps it. Raised, it unwound `run` and ended the cell before
878
+ # this reap answered the caller. EPERM means no member received the signal, the same outcome as ESRCH.
765
879
  def sweep_group(child)
766
880
  Process.kill :KILL, -child.pid
767
- rescue Errno::ESRCH
881
+ rescue Errno::ESRCH, Errno::EPERM
768
882
  nil
769
883
  end
770
884
 
771
885
  # The whole process group, which is the worker and everything it started. Negative pid is the group.
772
886
  # Falls back to the worker alone if the group is already gone, so a worker that died between the check
773
- # and the signal is not an error.
887
+ # and the signal is not an error. A group answering EPERM falls back as well, for the reason
888
+ # `sweep_group` gives; the bare pid does not, because macOS signals a zombie pid without complaint.
774
889
  def kill_group(child)
775
890
  Process.kill :KILL, -child.pid
776
- rescue Errno::ESRCH
891
+ rescue Errno::ESRCH, Errno::EPERM
777
892
  begin
778
893
  Process.kill :KILL, child.pid
779
894
  rescue Errno::ESRCH
@@ -810,6 +925,11 @@ module HotCell
810
925
  pid, status = Process.wait2(-1, Process::WNOHANG)
811
926
  break if pid.nil?
812
927
 
928
+ if @sweep&.pid == pid
929
+ reap_sweeper status
930
+ next
931
+ end
932
+
813
933
  child = @children.each_value.find { |candidate| candidate.pid == pid }
814
934
  next if child.nil?
815
935
 
@@ -836,6 +956,12 @@ module HotCell
836
956
 
837
957
  log.write "worker.reaped", pid: pid, slot: child.slot.number, served: child.served,
838
958
  signal: signal_name(status), exit_code: status.exitstatus
959
+
960
+ # A worker that exited without serving a request is not replaced. This guards against a worker
961
+ # that crashes during its startup, for example after a bad deploy. A replacement would crash too,
962
+ # and the supervisor would fork again, with no requests arriving. Instead, the next request forks
963
+ # its own worker.
964
+ prefork if child.served.positive?
839
965
  end
840
966
  rescue Errno::ECHILD
841
967
  nil
@@ -1137,11 +1263,19 @@ module HotCell
1137
1263
  Registry.operations.each { |operation| operation.before_fork.each(&:call) }
1138
1264
  end
1139
1265
 
1266
+ # Runs after `preload`, so that the objects the operations loaded are compacted too. Runs before the
1267
+ # first fork, so that each worker inherits the compacted heap. The compacted objects use fewer pages,
1268
+ # so a worker copies fewer pages when it writes to them.
1269
+ def warm_up
1270
+ Process.warmup
1271
+ end
1272
+
1140
1273
 
1141
1274
  def shutdown
1142
1275
  refuse_queue "the cell is stopping"
1143
1276
  @control_pending.each { |pending| pending.connection.close }
1144
1277
  @children.each_value { |child| child.control.close unless child.control.socket.closed? }
1278
+ kill_group @sweep if @sweep
1145
1279
  @work&.close
1146
1280
  @control&.close
1147
1281
  SOCKETS.each { |name| File.unlink socket_path(name) if File.socket?(socket_path(name)) }
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # Unlinks what the supervisor renamed aside, in a process of its own.
5
+ #
6
+ # How long a recursive delete takes is chosen by the input that filled the tree, so it runs in neither
7
+ # the supervisor, whose loop enforces every deadline, nor a worker's request. A worker sweeps its own
8
+ # slot after it has answered, but a worker killed at its deadline never reaches that ensure — and a slot
9
+ # whose every request is killed stacked one tree per kill until the scratch was full. This is the sweep
10
+ # that needs no request to run: the supervisor forks it on a timer, holds it to a deadline, and never
11
+ # runs two at once.
12
+ class Sweeper
13
+ def initialize(workspace:, configuration:, log:)
14
+ @workspace = workspace
15
+ @configuration = configuration
16
+ @log = log
17
+ end
18
+
19
+ # exit! for the reason Worker#run does: nothing inherited from the supervisor may run its teardown here.
20
+ #
21
+ # The cell's memory limit goes on first, and only that one. `FileUtils.remove_entry` lists a directory
22
+ # before it unlinks anything in it, so a tree one directory wide enough allocates in proportion to its
23
+ # width; RLIMIT_DATA makes that this process's NoMemoryError and a `sweeper.crashed` line. It is a bound
24
+ # on this process and not on the cell: the cgroup counts every worker and the tmpfs too, and can run out
25
+ # first. A sweep that dies this way makes no progress on that directory, and that is accepted here.
26
+ #
27
+ # Not `file_size`: this process writes nothing but log lines, and a log that is a regular file is past
28
+ # any worker's limit already, so the first line would have killed the sweeper with SIGXFSZ.
29
+ def run
30
+ configuration.limits.merge(file_size: nil, open_files: nil).apply
31
+ started = Clock.now
32
+ swept = slots.sum { |slot| sweep slot }
33
+
34
+ log.write "scratch.swept", pid: Process.pid, swept: swept, duration_ms: Clock.ms_since(started)
35
+ exit! 0
36
+ rescue Exception => error
37
+ log.write "sweeper.crashed", pid: Process.pid, error: error.class.name, message: Failure.sanitize(error.message)
38
+ exit! 1
39
+ end
40
+
41
+ private
42
+ attr_reader :workspace, :configuration, :log
43
+
44
+ def slots
45
+ (0...configuration.concurrency).map { |number| Slot.build(workspace, number) }
46
+ end
47
+
48
+ # A tree that would not go is the worker's `slot.unswept`, from this pid: the same fact, whoever
49
+ # noticed it. The glob can raise — the slot directory is a name a tool can replace — and that too is
50
+ # a slot left unswept rather than the end of the sweep.
51
+ def sweep(slot)
52
+ trees = slot.discarded
53
+ removed = trees.count { |path| Filesystem.remove_tree(path) }
54
+ report_unswept slot if removed < trees.size
55
+ removed
56
+ rescue SystemCallError
57
+ report_unswept slot
58
+ 0
59
+ end
60
+
61
+ def report_unswept(slot)
62
+ log.write "slot.unswept", pid: Process.pid, slot: slot.number, home: slot.directory
63
+ end
64
+ end
65
+ end
@@ -26,6 +26,9 @@ module HotCell
26
26
  # which is the only place such a library may be loaded.
27
27
  #
28
28
  # HotCell::TestCell.boot(operations: -> { require "active_storage/hot_cell/server" })
29
+ #
30
+ # The cell's sockets go beneath `Dir.tmpdir`, and a Unix socket path holds 104 bytes on macOS, whose per-user
31
+ # `TMPDIR` spends about half of that. A suite on macOS should set a short `TMPDIR`, such as `/tmp`.
29
32
  class TestCell
30
33
  READY = "up"
31
34
 
@@ -70,6 +70,16 @@ module HotCell
70
70
  end
71
71
  end
72
72
 
73
+ # A worker inherits the supervisor's GC counters at the fork. So this reports what the supervisor did to
74
+ # its heap before the fork.
75
+ class HeapState < HotCell::Operation
76
+ operation "test.heap_state"
77
+
78
+ def perform(_inputs, _outputs)
79
+ { compactions: GC.stat(:compact_count) }
80
+ end
81
+ end
82
+
73
83
  # Leaves its $HOME in a state the worker cannot remove, by taking write permission off a subdirectory
74
84
  # that still has a file in it. A tool running as this user can do the same to a sibling's directory,
75
85
  # which is why a removal that fails has to be reported rather than swallowed.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotcell-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.4.1
18
+ version: 0.6.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.4.1
25
+ version: 0.6.0
26
26
  description: |
27
27
  Runs a HotCell container. A supervisor listens on two Unix sockets, forks a worker for each request,
28
28
  and enforces a wall clock deadline and resource limits on it. Write the work as a subclass of
@@ -43,6 +43,7 @@ files:
43
43
  - lib/hot_cell/control.rb
44
44
  - lib/hot_cell/counters.rb
45
45
  - lib/hot_cell/filesystem.rb
46
+ - lib/hot_cell/health_operations.rb
46
47
  - lib/hot_cell/limits.rb
47
48
  - lib/hot_cell/log.rb
48
49
  - lib/hot_cell/operation.rb
@@ -52,6 +53,7 @@ files:
52
53
  - lib/hot_cell/server/version.rb
53
54
  - lib/hot_cell/slot.rb
54
55
  - lib/hot_cell/supervisor.rb
56
+ - lib/hot_cell/sweeper.rb
55
57
  - lib/hot_cell/test_cell.rb
56
58
  - lib/hot_cell/test_operations.rb
57
59
  - lib/hot_cell/timing.rb
@@ -62,8 +64,8 @@ licenses:
62
64
  - MIT
63
65
  metadata:
64
66
  homepage_uri: https://github.com/basecamp/hotcell
65
- source_code_uri: https://github.com/basecamp/hotcell/tree/v0.4.1/hotcell-server
66
- changelog_uri: https://github.com/basecamp/hotcell/blob/v0.4.1/CHANGELOG.md
67
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.6.0/hotcell-server
68
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.6.0/CHANGELOG.md
67
69
  bug_tracker_uri: https://github.com/basecamp/hotcell/issues
68
70
  rubygems_mfa_required: 'true'
69
71
  rdoc_options: []
@@ -80,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
80
82
  - !ruby/object:Gem::Version
81
83
  version: '0'
82
84
  requirements: []
83
- rubygems_version: 4.0.16
85
+ rubygems_version: 4.0.6
84
86
  specification_version: 4
85
87
  summary: 'Run a HotCell: the supervisor, the worker, and the operation API.'
86
88
  test_files: []